diff --git a/internal/app/app.go b/internal/app/app.go index b1ae292..2b109ad 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -129,43 +129,6 @@ type Model struct { // SSM session state selectedInstance *awsservice.EC2Instance - // VPC browser state - vpcs []awsservice.VPC - filteredVPCs []awsservice.VPC - vpcIdx int - subnets []awsservice.Subnet - filteredSubnets []awsservice.Subnet - subnetIdx int - selectedVPC *awsservice.VPC - selectedSubnet *awsservice.Subnet - availableIPs []string - filteredIPs []string - ipScrollOffset int - ipFilter string - ipFilterActive bool - reachabilityRegions []string - filteredReachabilityRegions []string - reachabilityRegion string - reachabilityRegionIdx int - reachabilityRegionFilter string - reachabilityRegionFiltering bool - reachabilityTargets []awsservice.ReachabilityTarget - filteredReachabilityTargets []awsservice.ReachabilityTarget - reachabilitySourceTypes []string - reachabilitySourceTypeIdx int - reachabilityDestTypes []string - reachabilityDestTypeIdx int - reachabilityIdx int - reachabilityFilter string - reachabilityFilterActive bool - reachabilitySource *awsservice.ReachabilityTarget - reachabilityDestination *awsservice.ReachabilityTarget - reachabilityDestinationIP string - reachabilityProtocolIdx int - reachabilityPortInput string - reachabilityConfigField int - reachabilityResult *awsservice.ReachabilityAnalysisResult - reachabilityScrollOffset int // Security Group browser state securityGroups []awsservice.SecurityGroup filteredSecurityGroups []awsservice.SecurityGroup @@ -180,26 +143,6 @@ type Model struct { sgAddInput string // current field text input sgAddSelectIdx int // index for select-type fields (direction, protocol) - // ECS browser state - ecsClusters []awsservice.ECSCluster - filteredECSClusters []awsservice.ECSCluster - ecsClusterIdx int - selectedECSCluster *awsservice.ECSCluster - - ecsServices []awsservice.ECSService - filteredECSServices []awsservice.ECSService - ecsServiceIdx int - selectedECSService *awsservice.ECSService - selectedECSDetail *awsservice.ECSServiceDetail - ecsDetailScroll int - - ecsTasks []awsservice.ECSTask - ecsTaskIdx int - selectedECSTask *awsservice.ECSTask - - ecsContainers []awsservice.ECSContainer - ecsContainerIdx int - // EKS browser state eksClusters []awsservice.EKSCluster filteredEKSClusters []awsservice.EKSCluster @@ -230,16 +173,19 @@ type Model struct { ecrCopyMsg string // Feature submodels - ec2Browser ec2InstanceBrowserModel - cwMetrics cloudWatchMetricsModel - cwLogs cloudWatchLogsModel - rds rdsModel - route53 route53Model - iam iamModel - bedrock bedrockModel - secrets secretsModel - s3 s3Model - lambda lambdaModel + ec2Browser ec2InstanceBrowserModel + ecs ecsModel + vpc vpcModel + reachability reachabilityModel + cwMetrics cloudWatchMetricsModel + cwLogs cloudWatchLogsModel + rds rdsModel + route53 route53Model + iam iamModel + bedrock bedrockModel + secrets secretsModel + s3 s3Model + lambda lambdaModel // Inspector browser state inspectorWorkflows []inspector.Workflow @@ -339,6 +285,9 @@ func New(cfg *config.Config, configPath string, version string, checklistPath .. contextTable: newContextTable(), } model.ec2Browser = newEC2InstanceBrowserModel() + model.ecs = newECSModel() + model.vpc = newVPCModel() + model.reachability = newReachabilityModel() model.cwMetrics = newCloudWatchMetricsModel() model.cwLogs = newCloudWatchLogsModel() model.rds = newRDSModel() @@ -445,7 +394,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { for _, h := range []func(tea.Msg) (tea.Model, tea.Cmd, bool){ m.handleEC2VPCMsg, m.handleSecurityGroupMsg, - m.handleECSMsg, m.handleEKSMsg, m.handleECRMsg, m.handleInspectorMsg, @@ -515,22 +463,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateFeatureList(msg) case screenInstanceList: return m.updateInstanceList(msg) - case screenVPCList: - return m.updateVPCList(msg) - case screenSubnetList: - return m.updateSubnetList(msg) - case screenSubnetDetail: - return m.updateSubnetDetail(msg) - case screenReachabilityRegionList: - return m.updateReachabilityRegionList(msg) - case screenReachabilitySourceList: - return m.updateReachabilitySourceList(msg) - case screenReachabilityDestinationList: - return m.updateReachabilityDestinationList(msg) - case screenReachabilityConfig: - return m.updateReachabilityConfig(msg) - case screenReachabilityResult: - return m.updateReachabilityResult(msg) case screenInspectorHome: return m.updateInspectorHome(msg) case screenInspectorWorkflowPlaceholder: @@ -553,16 +485,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateSecurityGroupAddRule(msg) case screenSecurityGroupDeleteConfirm: return m.updateSecurityGroupDeleteConfirm(msg) - case screenECSClusterList: - return m.updateECSClusterList(msg) - case screenECSServiceList: - return m.updateECSServiceList(msg) - case screenECSServiceDetail: - return m.updateECSServiceDetail(msg) - case screenECSTaskList: - return m.updateECSTaskList(msg) - case screenECSContainerList: - return m.updateECSContainerList(msg) case screenEKSClusterList: return m.updateEKSClusterList(msg) case screenEKSNodeGroupList: @@ -660,27 +582,9 @@ func (m Model) updateFeatureList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case domain.FeatureEC2InstanceBrowser: return m.ec2Browser.Start(&m) case domain.FeatureVPCBrowser: - return m.startLoading(m.loadVPCs()) + return m.vpc.Start(&m) case domain.FeatureReachabilityAnalyzer: - m.reachabilityRegions = availableReachabilityRegions(m.cfg.Region) - m.filteredReachabilityRegions = m.reachabilityRegions - m.reachabilityRegion = m.cfg.Region - m.reachabilityRegionIdx = indexOfString(m.reachabilityRegions, m.reachabilityRegion) - if m.reachabilityRegionIdx < 0 { - m.reachabilityRegionIdx = 0 - } - m.reachabilityRegionFilter = "" - m.reachabilityRegionFiltering = false - m.reachabilityTargets = nil - m.filteredReachabilityTargets = nil - m.reachabilitySource = nil - m.reachabilityDestination = nil - m.reachabilityDestinationIP = "" - m.reachabilityResult = nil - m.reachabilityScrollOffset = 0 - m.awsRepo = nil - m.screen = screenReachabilityRegionList - return m, nil + return m.reachability.Start(&m) case domain.FeatureRDSBrowser: return m.rds.Start(&m) case domain.FeatureRoute53Browser: @@ -702,7 +606,7 @@ func (m Model) updateFeatureList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case domain.FeatureRotateAccessKey: return m.iam.StartKeys(&m, true) case domain.FeatureECSExec: - return m.startLoading(m.loadECSClusters()) + return m.ecs.Start(&m) case domain.FeatureECRRepositoryBrowser: return m.startLoading(m.loadECRRepositories()) case domain.FeatureEKSBrowser: @@ -751,22 +655,6 @@ func (m Model) View() string { v = m.viewFeatureList() case screenInstanceList: v = m.viewInstanceList() - case screenVPCList: - v = m.viewVPCList() - case screenSubnetList: - v = m.viewSubnetList() - case screenSubnetDetail: - v = m.viewSubnetDetail() - case screenReachabilityRegionList: - v = m.viewReachabilityRegionList() - case screenReachabilitySourceList: - v = m.viewReachabilitySourceList() - case screenReachabilityDestinationList: - v = m.viewReachabilityDestinationList() - case screenReachabilityConfig: - v = m.viewReachabilityConfig() - case screenReachabilityResult: - v = m.viewReachabilityResult() case screenInspectorHome: v = m.viewInspectorHome() case screenInspectorWorkflowPlaceholder: @@ -791,16 +679,6 @@ func (m Model) View() string { v = m.viewSecurityGroupAddRule() case screenSecurityGroupDeleteConfirm: v = m.viewSecurityGroupDeleteConfirm() - case screenECSClusterList: - v = m.viewECSClusterList() - case screenECSServiceList: - v = m.viewECSServiceList() - case screenECSServiceDetail: - v = m.viewECSServiceDetail() - case screenECSTaskList: - v = m.viewECSTaskList() - case screenECSContainerList: - v = m.viewECSContainerList() case screenEKSClusterList: v = m.viewEKSClusterList() case screenEKSNodeGroupList: diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 123d07f..0c0c1d5 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -486,15 +486,15 @@ func TestReachabilityFeatureOpensRegionSelection(t *testing.T) { if model.screen != screenReachabilityRegionList { t.Fatalf("expected region selection screen, got %v", model.screen) } - if model.reachabilityRegion != "us-east-1" { - t.Fatalf("expected default reachability region us-east-1, got %q", model.reachabilityRegion) + if model.reachability.region != "us-east-1" { + t.Fatalf("expected default reachability region us-east-1, got %q", model.reachability.region) } } func TestReachabilityStatusBarUsesOverrideRegion(t *testing.T) { m := New(testConfig(), "", "dev") m.screen = screenReachabilitySourceList - m.reachabilityRegion = "ap-northeast-2" + m.reachability.region = "ap-northeast-2" bar := m.renderStatusBar() if !strings.Contains(bar, "region:ap-northeast-2") { @@ -511,19 +511,19 @@ func TestReachabilityTargetsLoadedBuildsSourceTypeFilter(t *testing.T) { }, } - updated, _, handled := m.handleEC2VPCMsg(msg) + updated, _, handled := m.reachability.HandleMessage(&m, msg) if !handled { t.Fatal("expected message to be handled") } model := updated.(Model) - if got := strings.Join(model.reachabilitySourceTypes, ","); got != "EC2 instances,Network interfaces" { + if got := strings.Join(model.reachability.sourceTypes, ","); got != "EC2 instances,Network interfaces" { t.Fatalf("unexpected source types: %q", got) } - if len(model.filteredReachabilityTargets) != 1 { - t.Fatalf("expected only EC2 instances to be visible initially, got %d", len(model.filteredReachabilityTargets)) + if len(model.reachability.filteredTargets) != 1 { + t.Fatalf("expected only EC2 instances to be visible initially, got %d", len(model.reachability.filteredTargets)) } - if model.filteredReachabilityTargets[0].Type != "EC2 instances" { - t.Fatalf("expected EC2 instances to be prioritized, got %+v", model.filteredReachabilityTargets) + if model.reachability.filteredTargets[0].Type != "EC2 instances" { + t.Fatalf("expected EC2 instances to be prioritized, got %+v", model.reachability.filteredTargets) } } @@ -2664,7 +2664,7 @@ func TestCWLogStreamsLoadedAppendExtendsExistingList(t *testing.T) { func TestReachabilityResultLinesUseReadableSections(t *testing.T) { m := New(testConfig(), "", "dev") - m.reachabilityResult = &awsservice.ReachabilityAnalysisResult{ + m.reachability.result = &awsservice.ReachabilityAnalysisResult{ Status: "failed", NetworkPathFound: false, Source: awsservice.ReachabilityTarget{Name: "src", ID: "eni-1"}, @@ -2679,7 +2679,7 @@ func TestReachabilityResultLinesUseReadableSections(t *testing.T) { }, } - lines := m.reachabilityResultLines() + lines := m.reachability.resultLines(m) rendered := strings.Join(lines, "\n") if !strings.Contains(rendered, "Summary") { t.Fatalf("expected Summary section, got %q", rendered) @@ -2694,13 +2694,13 @@ func TestReachabilityResultLinesUseReadableSections(t *testing.T) { func TestReachabilityLoadingDetailsShowSourceAndDestination(t *testing.T) { m := New(testConfig(), "", "dev") - m.reachabilityRegion = "ap-northeast-2" - m.reachabilitySource = &awsservice.ReachabilityTarget{Name: "source-eni", ID: "eni-1"} - m.reachabilityDestination = &awsservice.ReachabilityTarget{Name: "dest-eni", ID: "eni-2"} - m.reachabilityProtocolIdx = 0 - m.reachabilityPortInput = "443" + m.reachability.region = "ap-northeast-2" + m.reachability.source = &awsservice.ReachabilityTarget{Name: "source-eni", ID: "eni-1"} + m.reachability.destination = &awsservice.ReachabilityTarget{Name: "dest-eni", ID: "eni-2"} + m.reachability.protocolIdx = 0 + m.reachability.portInput = "443" - details := m.reachabilityLoadingDetails() + details := m.reachability.loadingDetails(m) if len(details) < 4 { t.Fatalf("expected vertical loading details, got %#v", details) } @@ -2727,10 +2727,10 @@ func TestReachabilityLoadingDetailsShowSourceAndDestination(t *testing.T) { func TestReachabilityLoadingDetailsTruncateLongLabelsForNarrowWidth(t *testing.T) { m := New(testConfig(), "", "dev") m.width = 30 - m.reachabilitySource = &awsservice.ReachabilityTarget{Name: strings.Repeat("source-", 8), ID: "eni-1"} - m.reachabilityDestination = &awsservice.ReachabilityTarget{Name: strings.Repeat("dest-", 8), ID: "eni-2"} + m.reachability.source = &awsservice.ReachabilityTarget{Name: strings.Repeat("source-", 8), ID: "eni-1"} + m.reachability.destination = &awsservice.ReachabilityTarget{Name: strings.Repeat("dest-", 8), ID: "eni-2"} - details := m.reachabilityLoadingDetails() + details := m.reachability.loadingDetails(m) if !strings.Contains(details[1], "…") { t.Fatalf("expected truncated source label, got %#v", details) } diff --git a/internal/app/feature_submodel.go b/internal/app/feature_submodel.go index 2cc8019..6a39cea 100644 --- a/internal/app/feature_submodel.go +++ b/internal/app/feature_submodel.go @@ -10,5 +10,5 @@ type featureSubmodel interface { } func (m *Model) featureSubmodels() []featureSubmodel { - return []featureSubmodel{&m.ec2Browser, &m.cwMetrics, &m.cwLogs, &m.rds, &m.route53, &m.iam, &m.bedrock, &m.secrets, &m.s3, &m.lambda} + return []featureSubmodel{&m.ec2Browser, &m.ecs, &m.vpc, &m.reachability, &m.cwMetrics, &m.cwLogs, &m.rds, &m.route53, &m.iam, &m.bedrock, &m.secrets, &m.s3, &m.lambda} } diff --git a/internal/app/filter.go b/internal/app/filter.go index 0b2a6fe..a30b0b4 100644 --- a/internal/app/filter.go +++ b/internal/app/filter.go @@ -168,17 +168,9 @@ func (m *Model) applyFilterTarget(target filterTarget) { case filterInstances: m.filtered = applyFilter(m.instances, m.filterValue(target)) m.instIdx = 0 - case filterSubnetIPs: - m.applyIPFilter() case filterSecurityGroups: m.filteredSecurityGroups = applyFilter(m.securityGroups, m.filterValue(target)) m.sgIdx = 0 - case filterECSClusters: - m.filteredECSClusters = applyFilter(m.ecsClusters, m.filterValue(target)) - m.ecsClusterIdx = 0 - case filterECSServices: - m.filteredECSServices = applyFilter(m.ecsServices, m.filterValue(target)) - m.ecsServiceIdx = 0 case filterEKSClusters: m.filteredEKSClusters = applyFilter(m.eksClusters, m.filterValue(target)) m.eksClusterIdx = 0 @@ -198,12 +190,6 @@ func (m *Model) applyFilterTarget(target filterTarget) { m.filteredCtxList = applyFilter(m.ctxList, m.filterValue(target)) m.ctxIdx = 0 m.syncContextTable() - case filterVPCs: - m.filteredVPCs = applyFilter(m.vpcs, m.filterValue(target)) - m.vpcIdx = 0 - case filterSubnets: - m.filteredSubnets = applyFilter(m.subnets, m.filterValue(target)) - m.subnetIdx = 0 case filterInspectorChecklistFiles: m.filteredChecklistFiles = applyFilter(m.inspectorChecklistFiles, m.filterValue(target)) m.inspectorChecklistFileIdx = 0 diff --git a/internal/app/help.go b/internal/app/help.go index e5dd8ce..738d08f 100644 --- a/internal/app/help.go +++ b/internal/app/help.go @@ -96,13 +96,13 @@ func (m Model) helpModeShortcuts() []helpShortcut { } shortcuts = append(shortcuts, helpShortcut{"esc", "Close filter mode"}) return shortcuts - case m.screen == screenReachabilityRegionList && m.reachabilityRegionFiltering: + case m.screen == screenReachabilityRegionList && m.reachability.regionFiltering: return []helpShortcut{ {"type", "Update the region filter"}, {"backspace", "Delete the previous character"}, {"enter / esc", "Close region filter mode"}, } - case (m.screen == screenReachabilitySourceList || m.screen == screenReachabilityDestinationList) && m.reachabilityFilterActive: + case (m.screen == screenReachabilitySourceList || m.screen == screenReachabilityDestinationList) && m.reachability.filterActive: return []helpShortcut{ {"type", "Update the target filter"}, {"backspace", "Delete the previous character"}, diff --git a/internal/app/screen_ec2.go b/internal/app/screen_ec2.go index 3b26a12..85033f3 100644 --- a/internal/app/screen_ec2.go +++ b/internal/app/screen_ec2.go @@ -19,53 +19,6 @@ func (m Model) handleEC2VPCMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { m.screen = screenInstanceList return m, nil, true - case vpcsLoadedMsg: - m.vpcs = msg.vpcs - m.resetFilter(filterVPCs) - m.vpcIdx = 0 - m.screen = screenVPCList - return m, nil, true - - case subnetsLoadedMsg: - m.subnets = msg.subnets - m.resetFilter(filterSubnets) - m.subnetIdx = 0 - m.screen = screenSubnetList - return m, nil, true - - case availableIPsLoadedMsg: - m.availableIPs = msg.ips - m.resetFilter(filterSubnetIPs) - m.screen = screenSubnetDetail - return m, nil, true - - case reachabilityTargetsLoadedMsg: - m.reachabilityTargets = msg.targets - m.reachabilitySourceTypes = buildReachabilityTargetTypes(msg.targets, false) - m.reachabilitySourceTypeIdx = 0 - m.reachabilityDestTypes = nil - m.reachabilityDestTypeIdx = 0 - m.filteredReachabilityTargets = applyReachabilityTargetFilter(msg.targets, m.selectedReachabilitySourceType(), "") - m.reachabilityIdx = 0 - m.reachabilityFilter = "" - m.reachabilityFilterActive = false - m.reachabilitySource = nil - m.reachabilityDestination = nil - m.reachabilityDestinationIP = "" - m.reachabilityProtocolIdx = 0 - m.reachabilityPortInput = "443" - m.reachabilityConfigField = 0 - m.reachabilityResult = nil - m.reachabilityScrollOffset = 0 - m.screen = screenReachabilitySourceList - return m, nil, true - - case reachabilityAnalysisLoadedMsg: - m.reachabilityResult = msg.result - m.reachabilityScrollOffset = 0 - m.screen = screenReachabilityResult - return m, nil, true - case ssmSessionDoneMsg: if msg.err != nil { m.errMsg = msg.err.Error() diff --git a/internal/app/screen_ecs.go b/internal/app/screen_ecs.go index 44dc93f..13720df 100644 --- a/internal/app/screen_ecs.go +++ b/internal/app/screen_ecs.go @@ -13,97 +13,171 @@ import ( const ecsAPITimeout = 30 * time.Second -// handleECSMsg routes ECS messages to the correct screen. -func (m Model) handleECSMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { +type ecsModel struct { + clusters []awsservice.ECSCluster + filteredClusters []awsservice.ECSCluster + clusterIdx int + selectedCluster *awsservice.ECSCluster + services []awsservice.ECSService + filteredServices []awsservice.ECSService + serviceIdx int + selectedService *awsservice.ECSService + selectedDetail *awsservice.ECSServiceDetail + detailScroll int + tasks []awsservice.ECSTask + taskIdx int + selectedTask *awsservice.ECSTask + containers []awsservice.ECSContainer + containerIdx int +} + +func newECSModel() ecsModel { + return ecsModel{} +} + +func (em *ecsModel) Start(m *Model) (tea.Model, tea.Cmd) { + return m.startLoading(em.loadClusters(*m)) +} + +func (em *ecsModel) HandleMessage(m *Model, msg tea.Msg) (tea.Model, tea.Cmd, bool) { switch msg := msg.(type) { case ecsClustersLoadedMsg: - m.ecsClusters = msg.clusters - m.filteredECSClusters = msg.clusters - m.ecsClusterIdx = 0 - m.selectedECSService = nil - m.selectedECSDetail = nil - m.ecsDetailScroll = 0 + em.clusters = msg.clusters + em.filteredClusters = msg.clusters + em.clusterIdx = 0 + em.selectedService = nil + em.selectedDetail = nil + em.detailScroll = 0 m.resetFilter(filterECSClusters) m.screen = screenECSClusterList - return m, nil, true - + return *m, nil, true case ecsServicesLoadedMsg: - m.ecsServices = msg.services - m.filteredECSServices = msg.services - m.ecsServiceIdx = 0 - m.selectedECSService = nil - m.selectedECSDetail = nil - m.ecsDetailScroll = 0 + em.services = msg.services + em.filteredServices = msg.services + em.serviceIdx = 0 + em.selectedService = nil + em.selectedDetail = nil + em.detailScroll = 0 m.resetFilter(filterECSServices) m.screen = screenECSServiceList - return m, nil, true - + return *m, nil, true case ecsServiceDetailLoadedMsg: - m.selectedECSDetail = msg.detail - m.ecsDetailScroll = 0 + em.selectedDetail = msg.detail + em.detailScroll = 0 if msg.detail != nil { summary := msg.detail.Summary() - m.selectedECSService = &summary - for i, svc := range m.ecsServices { + em.selectedService = &summary + for i, svc := range em.services { if svc.ARN == summary.ARN { - m.ecsServices[i] = summary + em.services[i] = summary } } - m.filteredECSServices = applyFilter(m.ecsServices, m.filterValue(filterECSServices)) + em.filteredServices = applyFilter(em.services, m.filterValue(filterECSServices)) } m.screen = screenECSServiceDetail - return m, nil, true - + return *m, nil, true case ecsTasksLoadedMsg: - m.ecsTasks = msg.tasks - m.ecsTaskIdx = 0 + em.tasks = msg.tasks + em.taskIdx = 0 m.screen = screenECSTaskList - return m, nil, true - + return *m, nil, true case ecsContainersLoadedMsg: - m.ecsContainers = msg.containers - m.ecsContainerIdx = 0 + em.containers = msg.containers + em.containerIdx = 0 m.screen = screenECSContainerList - return m, nil, true - + return *m, nil, true case ecsExecDoneMsg: m.screen = screenECSContainerList - return m, nil, true + return *m, nil, true + } + return *m, nil, false +} + +func (em *ecsModel) HandleKey(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { + switch m.screen { + case screenECSClusterList: + newM, cmd := em.updateClusterList(m, msg) + return newM, cmd, true + case screenECSServiceList: + newM, cmd := em.updateServiceList(m, msg) + return newM, cmd, true + case screenECSServiceDetail: + newM, cmd := em.updateServiceDetail(m, msg) + return newM, cmd, true + case screenECSTaskList: + newM, cmd := em.updateTaskList(m, msg) + return newM, cmd, true + case screenECSContainerList: + newM, cmd := em.updateContainerList(m, msg) + return newM, cmd, true + default: + return *m, nil, false + } +} + +func (em ecsModel) View(m Model) (string, bool) { + switch m.screen { + case screenECSClusterList: + return em.viewClusterList(m), true + case screenECSServiceList: + return em.viewServiceList(m), true + case screenECSServiceDetail: + return em.viewServiceDetail(m), true + case screenECSTaskList: + return em.viewTaskList(m), true + case screenECSContainerList: + return em.viewContainerList(m), true + default: + return "", false + } +} + +func (em *ecsModel) ApplyFilter(m *Model, target filterTarget) bool { + switch target { + case filterECSClusters: + em.filteredClusters = applyFilter(em.clusters, m.filterValue(target)) + em.clusterIdx = 0 + return true + case filterECSServices: + em.filteredServices = applyFilter(em.services, m.filterValue(target)) + em.serviceIdx = 0 + return true + default: + return false } - return m, nil, false } // --- Cluster List --- -func (m Model) updateECSClusterList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (em *ecsModel) updateClusterList(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() if cmd, handled := m.updateSharedFilter(msg, filterECSClusters); handled { - return m, cmd + return *m, cmd } switch key { case "q", "esc": m.screen = screenFeatureList case "up", "k": - m.ecsClusterIdx = previousListIndex(m.ecsClusterIdx, len(m.filteredECSClusters)) + em.clusterIdx = previousListIndex(em.clusterIdx, len(em.filteredClusters)) case "down", "j": - m.ecsClusterIdx = nextListIndex(m.ecsClusterIdx, len(m.filteredECSClusters)) + em.clusterIdx = nextListIndex(em.clusterIdx, len(em.filteredClusters)) case "/": - return m, m.activateFilter(filterECSClusters) + return *m, m.activateFilter(filterECSClusters) case "r": - return m.startLoading(m.loadECSClusters()) + return m.startLoading(em.loadClusters(*m)) case "enter": - if len(m.filteredECSClusters) > 0 && m.ecsClusterIdx < len(m.filteredECSClusters) { - cluster := m.filteredECSClusters[m.ecsClusterIdx] - m.selectedECSCluster = &cluster - return m.startLoading(m.loadECSServices()) + if len(em.filteredClusters) > 0 && em.clusterIdx < len(em.filteredClusters) { + cluster := em.filteredClusters[em.clusterIdx] + em.selectedCluster = &cluster + return m.startLoading(em.loadServices(*m)) } } - return m, nil + return *m, nil } -func (m Model) viewECSClusterList() string { +func (em ecsModel) viewClusterList(m Model) string { var b strings.Builder var panel strings.Builder b.WriteString(m.renderStatusBar()) @@ -113,23 +187,23 @@ func (m Model) viewECSClusterList() string { b.WriteString(m.renderFilterValue(filterECSClusters)) b.WriteString("\n\n") - if len(m.filteredECSClusters) == 0 { + if len(em.filteredClusters) == 0 { panel.WriteString(dimStyle.Render(" No clusters found")) panel.WriteString("\n") } else { // overhead: status bar (2) + title (1) + filter line (1) + blank (1) + list panel (2) + blank (1) + footer (1) = 10 visibleLines := max(m.height-10, 5) start := 0 - if m.ecsClusterIdx >= visibleLines { - start = m.ecsClusterIdx - visibleLines + 1 + if em.clusterIdx >= visibleLines { + start = em.clusterIdx - visibleLines + 1 } - end := min(start+visibleLines, len(m.filteredECSClusters)) + end := min(start+visibleLines, len(em.filteredClusters)) for i := start; i < end; i++ { - c := m.filteredECSClusters[i] + c := em.filteredClusters[i] cursor := " " style := normalStyle - if i == m.ecsClusterIdx { + if i == em.clusterIdx { cursor = "> " style = selectedStyle } @@ -137,7 +211,7 @@ func (m Model) viewECSClusterList() string { panel.WriteString("\n") } panel.WriteString("\n") - panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d clusters", len(m.filteredECSClusters), len(m.ecsClusters)))) + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d clusters", len(em.filteredClusters), len(em.clusters)))) } b.WriteString(m.renderListPanel(panel.String())) @@ -148,41 +222,41 @@ func (m Model) viewECSClusterList() string { // --- Service List --- -func (m Model) updateECSServiceList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (em *ecsModel) updateServiceList(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() if cmd, handled := m.updateSharedFilter(msg, filterECSServices); handled { - return m, cmd + return *m, cmd } switch key { case "q", "esc": m.screen = screenECSClusterList case "up", "k": - m.ecsServiceIdx = previousListIndex(m.ecsServiceIdx, len(m.filteredECSServices)) + em.serviceIdx = previousListIndex(em.serviceIdx, len(em.filteredServices)) case "down", "j": - m.ecsServiceIdx = nextListIndex(m.ecsServiceIdx, len(m.filteredECSServices)) + em.serviceIdx = nextListIndex(em.serviceIdx, len(em.filteredServices)) case "/": - return m, m.activateFilter(filterECSServices) + return *m, m.activateFilter(filterECSServices) case "r": - return m.startLoading(m.loadECSServices()) + return m.startLoading(em.loadServices(*m)) case "enter": - if len(m.filteredECSServices) > 0 && m.ecsServiceIdx < len(m.filteredECSServices) { - svc := m.filteredECSServices[m.ecsServiceIdx] - m.selectedECSService = &svc - return m.startLoading(m.loadECSServiceDetail()) + if len(em.filteredServices) > 0 && em.serviceIdx < len(em.filteredServices) { + svc := em.filteredServices[em.serviceIdx] + em.selectedService = &svc + return m.startLoading(em.loadServiceDetail(*m)) } } - return m, nil + return *m, nil } -func (m Model) viewECSServiceList() string { +func (em ecsModel) viewServiceList(m Model) string { var b strings.Builder var panel strings.Builder b.WriteString(m.renderStatusBar()) clusterName := "" - if m.selectedECSCluster != nil { - clusterName = m.selectedECSCluster.Name + if em.selectedCluster != nil { + clusterName = em.selectedCluster.Name } b.WriteString(titleStyle.Render(fmt.Sprintf("ECS Services — %s", clusterName))) b.WriteString("\n") @@ -190,23 +264,23 @@ func (m Model) viewECSServiceList() string { b.WriteString(m.renderFilterValue(filterECSServices)) b.WriteString("\n\n") - if len(m.filteredECSServices) == 0 { + if len(em.filteredServices) == 0 { panel.WriteString(dimStyle.Render(" No services found")) panel.WriteString("\n") } else { // overhead: status bar (2) + title (1) + filter line (1) + blank (1) + list panel (2) + blank (1) + footer (1) = 10 visibleLines := max(m.height-10, 5) start := 0 - if m.ecsServiceIdx >= visibleLines { - start = m.ecsServiceIdx - visibleLines + 1 + if em.serviceIdx >= visibleLines { + start = em.serviceIdx - visibleLines + 1 } - end := min(start+visibleLines, len(m.filteredECSServices)) + end := min(start+visibleLines, len(em.filteredServices)) for i := start; i < end; i++ { - s := m.filteredECSServices[i] + s := em.filteredServices[i] cursor := " " style := normalStyle - if i == m.ecsServiceIdx { + if i == em.serviceIdx { cursor = "> " style = selectedStyle } @@ -214,7 +288,7 @@ func (m Model) viewECSServiceList() string { panel.WriteString("\n") } panel.WriteString("\n") - panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d services", len(m.filteredECSServices), len(m.ecsServices)))) + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d services", len(em.filteredServices), len(em.services)))) } b.WriteString(m.renderListPanel(panel.String())) @@ -225,8 +299,8 @@ func (m Model) viewECSServiceList() string { // --- Service Detail --- -func (m Model) updateECSServiceDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - lines := m.ecsServiceDetailLines() +func (em *ecsModel) updateServiceDetail(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { + lines := em.serviceDetailLines() visibleLines := max(m.height-9, 5) maxOffset := max(len(lines)-visibleLines, 0) @@ -234,51 +308,51 @@ func (m Model) updateECSServiceDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "q", "esc": m.screen = screenECSServiceList case "up", "k": - if m.ecsDetailScroll > 0 { - m.ecsDetailScroll-- + if em.detailScroll > 0 { + em.detailScroll-- } case "down", "j": - if m.ecsDetailScroll < maxOffset { - m.ecsDetailScroll++ + if em.detailScroll < maxOffset { + em.detailScroll++ } case "pgup": - m.ecsDetailScroll -= visibleLines - if m.ecsDetailScroll < 0 { - m.ecsDetailScroll = 0 + em.detailScroll -= visibleLines + if em.detailScroll < 0 { + em.detailScroll = 0 } case "pgdown": - m.ecsDetailScroll += visibleLines - if m.ecsDetailScroll > maxOffset { - m.ecsDetailScroll = maxOffset + em.detailScroll += visibleLines + if em.detailScroll > maxOffset { + em.detailScroll = maxOffset } case "r": - return m.startLoading(m.loadECSServiceDetail()) + return m.startLoading(em.loadServiceDetail(*m)) case "enter": - return m.startLoading(m.loadECSTasks()) + return m.startLoading(em.loadTasks(*m)) } - return m, nil + return *m, nil } -func (m Model) viewECSServiceDetail() string { - if m.selectedECSDetail == nil { +func (em ecsModel) viewServiceDetail(m Model) string { + if em.selectedDetail == nil { return "" } var b strings.Builder var panel strings.Builder - detail := m.selectedECSDetail + detail := em.selectedDetail b.WriteString(m.renderStatusBar()) b.WriteString(titleStyle.Render(fmt.Sprintf("ECS Service Rollout — %s", detail.Name))) b.WriteString("\n\n") - lines := m.ecsServiceDetailLines() + lines := em.serviceDetailLines() if len(lines) == 0 { panel.WriteString(dimStyle.Render(" No rollout detail available")) panel.WriteString("\n") } else { visibleLines := max(m.height-9, 5) - start := m.ecsDetailScroll + start := em.detailScroll maxOffset := max(len(lines)-visibleLines, 0) if start > maxOffset { start = maxOffset @@ -301,12 +375,12 @@ func (m Model) viewECSServiceDetail() string { return b.String() } -func (m Model) ecsServiceDetailLines() []string { - if m.selectedECSDetail == nil { +func (em ecsModel) serviceDetailLines() []string { + if em.selectedDetail == nil { return nil } - detail := m.selectedECSDetail + detail := em.selectedDetail lines := []string{ renderDetailLine("Status", renderECSServiceStatus(detail.Status)), renderDetailLine("Launch", renderECSValue(detail.LaunchType)), @@ -366,54 +440,54 @@ func (m Model) ecsServiceDetailLines() []string { // --- Task List --- -func (m Model) updateECSTaskList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (em *ecsModel) updateTaskList(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "esc": m.screen = screenECSServiceDetail case "up", "k": - m.ecsTaskIdx = previousListIndex(m.ecsTaskIdx, len(m.ecsTasks)) + em.taskIdx = previousListIndex(em.taskIdx, len(em.tasks)) case "down", "j": - m.ecsTaskIdx = nextListIndex(m.ecsTaskIdx, len(m.ecsTasks)) + em.taskIdx = nextListIndex(em.taskIdx, len(em.tasks)) case "r": - return m.startLoading(m.loadECSTasks()) + return m.startLoading(em.loadTasks(*m)) case "enter": - if len(m.ecsTasks) > 0 && m.ecsTaskIdx < len(m.ecsTasks) { - task := m.ecsTasks[m.ecsTaskIdx] - m.selectedECSTask = &task - return m.startLoading(m.loadECSContainers()) + if len(em.tasks) > 0 && em.taskIdx < len(em.tasks) { + task := em.tasks[em.taskIdx] + em.selectedTask = &task + return m.startLoading(em.loadContainers(*m)) } } - return m, nil + return *m, nil } -func (m Model) viewECSTaskList() string { +func (em ecsModel) viewTaskList(m Model) string { var b strings.Builder var panel strings.Builder b.WriteString(m.renderStatusBar()) svcName := "" - if m.selectedECSService != nil { - svcName = m.selectedECSService.Name + if em.selectedService != nil { + svcName = em.selectedService.Name } b.WriteString(titleStyle.Render(fmt.Sprintf("ECS Tasks — %s", svcName))) b.WriteString("\n\n") - if len(m.ecsTasks) == 0 { + if len(em.tasks) == 0 { panel.WriteString(dimStyle.Render(" No running tasks found")) panel.WriteString("\n") } else { // overhead: status bar (2) + title (1) + blank (1) + list panel (2) + blank (1) + footer (1) = 9 visibleLines := max(m.height-9, 5) start := 0 - if m.ecsTaskIdx >= visibleLines { - start = m.ecsTaskIdx - visibleLines + 1 + if em.taskIdx >= visibleLines { + start = em.taskIdx - visibleLines + 1 } - end := min(start+visibleLines, len(m.ecsTasks)) + end := min(start+visibleLines, len(em.tasks)) for i := start; i < end; i++ { - t := m.ecsTasks[i] + t := em.tasks[i] cursor := " " style := normalStyle - if i == m.ecsTaskIdx { + if i == em.taskIdx { cursor = "> " style = selectedStyle } @@ -421,7 +495,7 @@ func (m Model) viewECSTaskList() string { panel.WriteString("\n") } panel.WriteString("\n") - panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d tasks", len(m.ecsTasks)))) + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d tasks", len(em.tasks)))) } b.WriteString(m.renderListPanel(panel.String())) @@ -432,59 +506,59 @@ func (m Model) viewECSTaskList() string { // --- Container List --- -func (m Model) updateECSContainerList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (em *ecsModel) updateContainerList(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "esc": m.screen = screenECSTaskList case "up", "k": - m.ecsContainerIdx = previousListIndex(m.ecsContainerIdx, len(m.ecsContainers)) + em.containerIdx = previousListIndex(em.containerIdx, len(em.containers)) case "down", "j": - m.ecsContainerIdx = nextListIndex(m.ecsContainerIdx, len(m.ecsContainers)) + em.containerIdx = nextListIndex(em.containerIdx, len(em.containers)) case "enter": - if len(m.ecsContainers) > 0 && m.ecsContainerIdx < len(m.ecsContainers) { - container := m.ecsContainers[m.ecsContainerIdx] + if len(em.containers) > 0 && em.containerIdx < len(em.containers) { + container := em.containers[em.containerIdx] if !container.ExecEnabled { m.errMsg = fmt.Sprintf( "ECS Exec is not enabled for container %q.\n\nTo enable it, update the task definition with enableExecuteCommand=true\nand ensure the task IAM role has ssmmessages permissions.", container.Name, ) m.screen = screenError - return m, nil + return *m, nil } - return m, m.startECSExec(container) + return *m, em.startExec(*m, container) } } - return m, nil + return *m, nil } -func (m Model) viewECSContainerList() string { +func (em ecsModel) viewContainerList(m Model) string { var b strings.Builder var panel strings.Builder b.WriteString(m.renderStatusBar()) taskID := "" - if m.selectedECSTask != nil { - taskID = m.selectedECSTask.TaskID + if em.selectedTask != nil { + taskID = em.selectedTask.TaskID } b.WriteString(titleStyle.Render(fmt.Sprintf("ECS Containers — %s", taskID))) b.WriteString("\n\n") - if len(m.ecsContainers) == 0 { + if len(em.containers) == 0 { panel.WriteString(dimStyle.Render(" No containers found")) panel.WriteString("\n") } else { // overhead: status bar (2) + title (1) + blank (1) + list panel (2) + blank (1) + footer (1) = 9 visibleLines := max(m.height-9, 5) start := 0 - if m.ecsContainerIdx >= visibleLines { - start = m.ecsContainerIdx - visibleLines + 1 + if em.containerIdx >= visibleLines { + start = em.containerIdx - visibleLines + 1 } - end := min(start+visibleLines, len(m.ecsContainers)) + end := min(start+visibleLines, len(em.containers)) for i := start; i < end; i++ { - c := m.ecsContainers[i] + c := em.containers[i] cursor := " " style := normalStyle - if i == m.ecsContainerIdx { + if i == em.containerIdx { cursor = "> " style = selectedStyle } @@ -492,7 +566,7 @@ func (m Model) viewECSContainerList() string { panel.WriteString("\n") } panel.WriteString("\n") - panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d containers", len(m.ecsContainers)))) + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d containers", len(em.containers)))) } b.WriteString(m.renderListPanel(panel.String())) @@ -503,7 +577,7 @@ func (m Model) viewECSContainerList() string { // --- Load Commands --- -func (m Model) loadECSClusters() tea.Cmd { +func (em ecsModel) loadClusters(m Model) tea.Cmd { return func() tea.Msg { if err := awsservice.CheckAWSCLIInstalled(); err != nil { return errMsg{err: err} @@ -525,9 +599,9 @@ func (m Model) loadECSClusters() tea.Cmd { } } -func (m Model) loadECSServices() tea.Cmd { +func (em ecsModel) loadServices(m Model) tea.Cmd { return func() tea.Msg { - if m.selectedECSCluster == nil { + if em.selectedCluster == nil { return errMsg{err: fmt.Errorf("no cluster selected")} } ctx, cancel := context.WithTimeout(context.Background(), ecsAPITimeout) @@ -536,20 +610,20 @@ func (m Model) loadECSServices() tea.Cmd { if err != nil { return errMsg{err: err} } - services, err := repo.ListServices(ctx, m.selectedECSCluster.ARN) + services, err := repo.ListServices(ctx, em.selectedCluster.ARN) if err != nil { return errMsg{err: err} } if len(services) == 0 { - return errMsg{err: fmt.Errorf("no services found in cluster %s", m.selectedECSCluster.Name)} + return errMsg{err: fmt.Errorf("no services found in cluster %s", em.selectedCluster.Name)} } return ecsServicesLoadedMsg{services: services} } } -func (m Model) loadECSTasks() tea.Cmd { +func (em ecsModel) loadTasks(m Model) tea.Cmd { return func() tea.Msg { - if m.selectedECSCluster == nil || m.selectedECSService == nil { + if em.selectedCluster == nil || em.selectedService == nil { return errMsg{err: fmt.Errorf("no cluster or service selected")} } ctx, cancel := context.WithTimeout(context.Background(), ecsAPITimeout) @@ -558,20 +632,20 @@ func (m Model) loadECSTasks() tea.Cmd { if err != nil { return errMsg{err: err} } - tasks, err := repo.ListTasks(ctx, m.selectedECSCluster.ARN, m.selectedECSService.ARN) + tasks, err := repo.ListTasks(ctx, em.selectedCluster.ARN, em.selectedService.ARN) if err != nil { return errMsg{err: err} } if len(tasks) == 0 { - return errMsg{err: fmt.Errorf("no running tasks found for service %s", m.selectedECSService.Name)} + return errMsg{err: fmt.Errorf("no running tasks found for service %s", em.selectedService.Name)} } return ecsTasksLoadedMsg{tasks: tasks} } } -func (m Model) loadECSServiceDetail() tea.Cmd { +func (em ecsModel) loadServiceDetail(m Model) tea.Cmd { return func() tea.Msg { - if m.selectedECSCluster == nil || m.selectedECSService == nil { + if em.selectedCluster == nil || em.selectedService == nil { return errMsg{err: fmt.Errorf("no cluster or service selected")} } ctx, cancel := context.WithTimeout(context.Background(), ecsAPITimeout) @@ -580,7 +654,7 @@ func (m Model) loadECSServiceDetail() tea.Cmd { if err != nil { return errMsg{err: err} } - detail, err := repo.DescribeServiceDetail(ctx, m.selectedECSCluster.ARN, m.selectedECSService.ARN) + detail, err := repo.DescribeServiceDetail(ctx, em.selectedCluster.ARN, em.selectedService.ARN) if err != nil { return errMsg{err: err} } @@ -588,9 +662,9 @@ func (m Model) loadECSServiceDetail() tea.Cmd { } } -func (m Model) loadECSContainers() tea.Cmd { +func (em ecsModel) loadContainers(m Model) tea.Cmd { return func() tea.Msg { - if m.selectedECSCluster == nil || m.selectedECSTask == nil { + if em.selectedCluster == nil || em.selectedTask == nil { return errMsg{err: fmt.Errorf("no cluster or task selected")} } ctx, cancel := context.WithTimeout(context.Background(), ecsAPITimeout) @@ -599,21 +673,21 @@ func (m Model) loadECSContainers() tea.Cmd { if err != nil { return errMsg{err: err} } - containers, err := repo.DescribeTaskContainers(ctx, m.selectedECSCluster.ARN, m.selectedECSTask.TaskARN) + containers, err := repo.DescribeTaskContainers(ctx, em.selectedCluster.ARN, em.selectedTask.TaskARN) if err != nil { return errMsg{err: err} } if len(containers) == 0 { - return errMsg{err: fmt.Errorf("no containers found for task %s", m.selectedECSTask.TaskID)} + return errMsg{err: fmt.Errorf("no containers found for task %s", em.selectedTask.TaskID)} } return ecsContainersLoadedMsg{containers: containers} } } // startECSExec launches an ECS exec session for the given container. -func (m Model) startECSExec(container awsservice.ECSContainer) tea.Cmd { +func (em ecsModel) startExec(m Model, container awsservice.ECSContainer) tea.Cmd { return func() tea.Msg { - if m.selectedECSCluster == nil || m.selectedECSTask == nil { + if em.selectedCluster == nil || em.selectedTask == nil { return errMsg{err: fmt.Errorf("no cluster or task selected")} } @@ -631,8 +705,8 @@ func (m Model) startECSExec(container awsservice.ECSContainer) tea.Cmd { } cmd := awsservice.BuildECSExecCommand( - m.selectedECSCluster.ARN, - m.selectedECSTask.TaskARN, + em.selectedCluster.ARN, + em.selectedTask.TaskARN, container.Name, m.cfg.Region, credEnv, diff --git a/internal/app/screen_ecs_test.go b/internal/app/screen_ecs_test.go index d4bcae0..7b5421f 100644 --- a/internal/app/screen_ecs_test.go +++ b/internal/app/screen_ecs_test.go @@ -13,11 +13,11 @@ import ( func TestECSServiceListEnterLoadsServiceDetail(t *testing.T) { m := New(testConfig(), "", "dev") m.screen = screenECSServiceList - m.selectedECSCluster = &awsservice.ECSCluster{Name: "prod-cluster", ARN: "arn:aws:ecs:us-east-1:123456789012:cluster/prod-cluster"} - m.ecsServices = []awsservice.ECSService{ + m.ecs.selectedCluster = &awsservice.ECSCluster{Name: "prod-cluster", ARN: "arn:aws:ecs:us-east-1:123456789012:cluster/prod-cluster"} + m.ecs.services = []awsservice.ECSService{ {Name: "api-service", ARN: "arn:aws:ecs:us-east-1:123456789012:service/prod-cluster/api-service", Status: "ACTIVE", RunningCount: 2, DesiredCount: 3, PendingCount: 1, LaunchType: "FARGATE"}, } - m.filteredECSServices = m.ecsServices + m.ecs.filteredServices = m.ecs.services updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) model := updated.(Model) @@ -27,8 +27,8 @@ func TestECSServiceListEnterLoadsServiceDetail(t *testing.T) { if model.screen != screenLoading { t.Fatalf("expected loading screen, got %v", model.screen) } - if model.selectedECSService == nil || model.selectedECSService.Name != "api-service" { - t.Fatalf("expected selected ECS service api-service, got %+v", model.selectedECSService) + if model.ecs.selectedService == nil || model.ecs.selectedService.Name != "api-service" { + t.Fatalf("expected selected ECS service api-service, got %+v", model.ecs.selectedService) } } @@ -46,7 +46,7 @@ func TestHandleECSServiceDetailLoadedMsgShowsDetailScreen(t *testing.T) { TaskDefinitionRevision: 42, } - updated, _, handled := m.handleECSMsg(ecsServiceDetailLoadedMsg{detail: detail}) + updated, _, handled := m.ecs.HandleMessage(&m, ecsServiceDetailLoadedMsg{detail: detail}) if !handled { t.Fatal("expected detail message to be handled") } @@ -55,11 +55,11 @@ func TestHandleECSServiceDetailLoadedMsgShowsDetailScreen(t *testing.T) { if model.screen != screenECSServiceDetail { t.Fatalf("expected ECS service detail screen, got %v", model.screen) } - if model.selectedECSDetail == nil || model.selectedECSDetail.Name != "api-service" { - t.Fatalf("expected selected ECS detail api-service, got %+v", model.selectedECSDetail) + if model.ecs.selectedDetail == nil || model.ecs.selectedDetail.Name != "api-service" { + t.Fatalf("expected selected ECS detail api-service, got %+v", model.ecs.selectedDetail) } - if model.selectedECSService == nil || model.selectedECSService.PendingCount != 1 { - t.Fatalf("expected service summary sync, got %+v", model.selectedECSService) + if model.ecs.selectedService == nil || model.ecs.selectedService.PendingCount != 1 { + t.Fatalf("expected service summary sync, got %+v", model.ecs.selectedService) } } @@ -68,7 +68,7 @@ func TestECSServiceDetailViewShowsRolloutAndImages(t *testing.T) { m.width = 120 m.height = 36 m.screen = screenECSServiceDetail - m.selectedECSDetail = &awsservice.ECSServiceDetail{ + m.ecs.selectedDetail = &awsservice.ECSServiceDetail{ Name: "api-service", Status: "ACTIVE", LaunchType: "FARGATE", @@ -108,7 +108,7 @@ func TestECSServiceDetailViewShowsRolloutAndImages(t *testing.T) { }, } - view := stripANSI(m.viewECSServiceDetail()) + view := stripANSI(m.ecs.viewServiceDetail(m)) for _, want := range []string{ "ECS Service Rollout", "running:2 desired:3 pending:1", @@ -127,7 +127,7 @@ func TestECSServiceDetailViewShowsRolloutAndImages(t *testing.T) { func TestECSServiceDetailEscReturnsToServiceList(t *testing.T) { m := New(testConfig(), "", "dev") m.screen = screenECSServiceDetail - m.selectedECSDetail = &awsservice.ECSServiceDetail{Name: "api-service"} + m.ecs.selectedDetail = &awsservice.ECSServiceDetail{Name: "api-service"} updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) model := updated.(Model) diff --git a/internal/app/screen_reachability.go b/internal/app/screen_reachability.go index 2c1acb1..e1f4093 100644 --- a/internal/app/screen_reachability.go +++ b/internal/app/screen_reachability.go @@ -16,12 +16,145 @@ import ( var reachabilityProtocols = []string{"TCP", "UDP"} -func (m Model) loadReachabilityTargets() tea.Cmd { +type reachabilityModel struct { + regions []string + filteredRegions []string + region string + regionIdx int + regionFilter string + regionFiltering bool + targets []awsservice.ReachabilityTarget + filteredTargets []awsservice.ReachabilityTarget + sourceTypes []string + sourceTypeIdx int + destTypes []string + destTypeIdx int + idx int + filter string + filterActive bool + source *awsservice.ReachabilityTarget + destination *awsservice.ReachabilityTarget + destinationIP string + protocolIdx int + portInput string + configField int + result *awsservice.ReachabilityAnalysisResult + scrollOffset int +} + +func newReachabilityModel() reachabilityModel { + return reachabilityModel{} +} + +func (rm *reachabilityModel) Start(m *Model) (tea.Model, tea.Cmd) { + rm.regions = availableReachabilityRegions(m.cfg.Region) + rm.filteredRegions = rm.regions + rm.region = m.cfg.Region + rm.regionIdx = indexOfString(rm.regions, rm.region) + if rm.regionIdx < 0 { + rm.regionIdx = 0 + } + rm.regionFilter = "" + rm.regionFiltering = false + rm.targets = nil + rm.filteredTargets = nil + rm.source = nil + rm.destination = nil + rm.destinationIP = "" + rm.result = nil + rm.scrollOffset = 0 + m.awsRepo = nil + m.screen = screenReachabilityRegionList + return *m, nil +} + +func (rm *reachabilityModel) HandleMessage(m *Model, msg tea.Msg) (tea.Model, tea.Cmd, bool) { + switch msg := msg.(type) { + case reachabilityTargetsLoadedMsg: + rm.targets = msg.targets + rm.sourceTypes = buildReachabilityTargetTypes(msg.targets, false) + rm.sourceTypeIdx = 0 + rm.destTypes = nil + rm.destTypeIdx = 0 + rm.filteredTargets = applyReachabilityTargetFilter(msg.targets, rm.selectedSourceType(), "") + rm.idx = 0 + rm.filter = "" + rm.filterActive = false + rm.source = nil + rm.destination = nil + rm.destinationIP = "" + rm.protocolIdx = 0 + rm.portInput = "443" + rm.configField = 0 + rm.result = nil + rm.scrollOffset = 0 + m.screen = screenReachabilitySourceList + return *m, nil, true + case reachabilityAnalysisLoadedMsg: + rm.result = msg.result + rm.scrollOffset = 0 + m.screen = screenReachabilityResult + return *m, nil, true + } + return *m, nil, false +} + +func (rm *reachabilityModel) HandleKey(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { + switch m.screen { + case screenReachabilityRegionList: + newM, cmd := rm.updateRegionList(m, msg) + return newM, cmd, true + case screenReachabilitySourceList: + newM, cmd := rm.updateSourceList(m, msg) + return newM, cmd, true + case screenReachabilityDestinationList: + newM, cmd := rm.updateDestinationList(m, msg) + return newM, cmd, true + case screenReachabilityConfig: + newM, cmd := rm.updateConfig(m, msg) + return newM, cmd, true + case screenReachabilityResult: + newM, cmd := rm.updateResult(m, msg) + return newM, cmd, true + default: + return *m, nil, false + } +} + +func (rm reachabilityModel) View(m Model) (string, bool) { + switch m.screen { + case screenReachabilityRegionList: + return rm.viewRegionList(m), true + case screenReachabilitySourceList: + return rm.viewSourceList(m), true + case screenReachabilityDestinationList: + return rm.viewDestinationList(m), true + case screenReachabilityConfig: + return rm.viewConfig(m), true + case screenReachabilityResult: + return rm.viewResult(m), true + default: + return "", false + } +} + +func (rm *reachabilityModel) ApplyFilter(_ *Model, _ filterTarget) bool { + return false +} + +func (rm reachabilityModel) activeRegion(m Model) string { + if strings.TrimSpace(rm.region) != "" { + return rm.region + } + return m.cfg.Region +} + +func (rm reachabilityModel) loadReachabilityTargets(m Model) tea.Cmd { return func() tea.Msg { ctx := context.Background() reachabilityCfg := *m.cfg - if strings.TrimSpace(m.reachabilityRegion) != "" { - reachabilityCfg.Region = strings.TrimSpace(m.reachabilityRegion) + if strings.TrimSpace(rm.region) != "" { + reachabilityCfg.Region = strings.TrimSpace(rm.region) } repo, err := awsservice.NewAwsRepository(ctx, &reachabilityCfg) if err != nil { @@ -41,44 +174,44 @@ func (m Model) loadReachabilityTargets() tea.Cmd { } } -func (m Model) updateReachabilityRegionList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (rm *reachabilityModel) updateRegionList(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() - if m.reachabilityRegionFiltering { - newFilter, deactivate, changed := handleFilterKey(key, m.reachabilityRegionFilter) - m.reachabilityRegionFilter = newFilter + if rm.regionFiltering { + newFilter, deactivate, changed := handleFilterKey(key, rm.regionFilter) + rm.regionFilter = newFilter if deactivate { - m.reachabilityRegionFiltering = false + rm.regionFiltering = false } if changed { - m.filteredReachabilityRegions = applyStringFilter(m.reachabilityRegions, m.reachabilityRegionFilter) - m.reachabilityRegionIdx = 0 + rm.filteredRegions = applyStringFilter(rm.regions, rm.regionFilter) + rm.regionIdx = 0 } - return m, nil + return *m, nil } switch key { case "q", "esc": m.screen = screenFeatureList case "up", "k": - m.reachabilityRegionIdx = previousListIndex(m.reachabilityRegionIdx, len(m.filteredReachabilityRegions)) + rm.regionIdx = previousListIndex(rm.regionIdx, len(rm.filteredRegions)) case "down", "j": - m.reachabilityRegionIdx = nextListIndex(m.reachabilityRegionIdx, len(m.filteredReachabilityRegions)) + rm.regionIdx = nextListIndex(rm.regionIdx, len(rm.filteredRegions)) case "/": - m.reachabilityRegionFiltering = true + rm.regionFiltering = true case "enter": - if len(m.filteredReachabilityRegions) == 0 { - return m, nil - } - m.reachabilityRegion = m.filteredReachabilityRegions[m.reachabilityRegionIdx] - m.reachabilityTargets = nil - m.filteredReachabilityTargets = nil - m.reachabilitySource = nil - m.reachabilityDestination = nil - m.reachabilityDestinationIP = "" - m.reachabilityResult = nil - m.reachabilityScrollOffset = 0 - m.reachabilityFilter = "" - m.reachabilityFilterActive = false + if len(rm.filteredRegions) == 0 { + return *m, nil + } + rm.region = rm.filteredRegions[rm.regionIdx] + rm.targets = nil + rm.filteredTargets = nil + rm.source = nil + rm.destination = nil + rm.destinationIP = "" + rm.result = nil + rm.scrollOffset = 0 + rm.filter = "" + rm.filterActive = false m.awsRepo = nil return m.startLoadingWithMessage( "Loading reachability targets...", @@ -86,13 +219,13 @@ func (m Model) updateReachabilityRegionList(msg tea.KeyMsg) (tea.Model, tea.Cmd) fmt.Sprintf("Region: %s", m.activeReachabilityRegion()), "Collecting source and destination candidates for Reachability Analyzer.", }, - m.loadReachabilityTargets(), + rm.loadReachabilityTargets(*m), ) } - return m, nil + return *m, nil } -func (m Model) runReachabilityAnalysis() tea.Cmd { +func (rm reachabilityModel) runAnalysis(m Model) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() @@ -100,8 +233,8 @@ func (m Model) runReachabilityAnalysis() tea.Cmd { if repo == nil { var err error reachabilityCfg := *m.cfg - if strings.TrimSpace(m.reachabilityRegion) != "" { - reachabilityCfg.Region = strings.TrimSpace(m.reachabilityRegion) + if strings.TrimSpace(rm.region) != "" { + reachabilityCfg.Region = strings.TrimSpace(rm.region) } repo, err = awsservice.NewAwsRepository(ctx, &reachabilityCfg) if err != nil { @@ -109,19 +242,19 @@ func (m Model) runReachabilityAnalysis() tea.Cmd { } } - port, err := strconv.Atoi(strings.TrimSpace(m.reachabilityPortInput)) + port, err := strconv.Atoi(strings.TrimSpace(rm.portInput)) if err != nil || port <= 0 || port > 65535 { return errMsg{err: fmt.Errorf("destination port must be between 1 and 65535")} } - if m.reachabilitySource == nil { + if rm.source == nil { return errMsg{err: fmt.Errorf("source is required")} } var destination awsservice.ReachabilityTarget - destinationIP := strings.TrimSpace(m.reachabilityDestinationIP) - if m.reachabilityDestination != nil && !m.reachabilityDestination.ManualIP { - destination = *m.reachabilityDestination + destinationIP := strings.TrimSpace(rm.destinationIP) + if rm.destination != nil && !rm.destination.ManualIP { + destination = *rm.destination destinationIP = "" } if destination.ID == "" && destinationIP == "" { @@ -136,10 +269,10 @@ func (m Model) runReachabilityAnalysis() tea.Cmd { result, err := repo.RunReachabilityAnalysis( ctx, - *m.reachabilitySource, + *rm.source, destination, destinationIP, - reachabilityProtocols[m.reachabilityProtocolIdx], + reachabilityProtocols[rm.protocolIdx], int32(port), ) if err != nil { @@ -149,137 +282,137 @@ func (m Model) runReachabilityAnalysis() tea.Cmd { } } -func (m Model) updateReachabilitySourceList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (rm *reachabilityModel) updateSourceList(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() - if m.reachabilityFilterActive { - newFilter, deactivate, changed := handleFilterKey(key, m.reachabilityFilter) - m.reachabilityFilter = newFilter + if rm.filterActive { + newFilter, deactivate, changed := handleFilterKey(key, rm.filter) + rm.filter = newFilter if deactivate { - m.reachabilityFilterActive = false + rm.filterActive = false } if changed { - m.filteredReachabilityTargets = applyReachabilityTargetFilter(m.reachabilityTargets, m.selectedReachabilitySourceType(), m.reachabilityFilter) - m.reachabilityIdx = 0 + rm.filteredTargets = applyReachabilityTargetFilter(rm.targets, rm.selectedSourceType(), rm.filter) + rm.idx = 0 } - return m, nil + return *m, nil } switch key { case "q", "esc": m.screen = screenReachabilityRegionList case "left", "h": - if m.reachabilitySourceTypeIdx > 0 { - m.reachabilitySourceTypeIdx-- - m.filteredReachabilityTargets = applyReachabilityTargetFilter(m.reachabilityTargets, m.selectedReachabilitySourceType(), m.reachabilityFilter) - m.reachabilityIdx = 0 + if rm.sourceTypeIdx > 0 { + rm.sourceTypeIdx-- + rm.filteredTargets = applyReachabilityTargetFilter(rm.targets, rm.selectedSourceType(), rm.filter) + rm.idx = 0 } case "right", "l", "tab": - if m.reachabilitySourceTypeIdx < len(m.reachabilitySourceTypes)-1 { - m.reachabilitySourceTypeIdx++ - m.filteredReachabilityTargets = applyReachabilityTargetFilter(m.reachabilityTargets, m.selectedReachabilitySourceType(), m.reachabilityFilter) - m.reachabilityIdx = 0 + if rm.sourceTypeIdx < len(rm.sourceTypes)-1 { + rm.sourceTypeIdx++ + rm.filteredTargets = applyReachabilityTargetFilter(rm.targets, rm.selectedSourceType(), rm.filter) + rm.idx = 0 } case "up", "k": - m.reachabilityIdx = previousListIndex(m.reachabilityIdx, len(m.filteredReachabilityTargets)) + rm.idx = previousListIndex(rm.idx, len(rm.filteredTargets)) case "down", "j": - m.reachabilityIdx = nextListIndex(m.reachabilityIdx, len(m.filteredReachabilityTargets)) + rm.idx = nextListIndex(rm.idx, len(rm.filteredTargets)) case "/": - m.reachabilityFilterActive = true + rm.filterActive = true case "r": return m.startLoadingWithMessage( "Refreshing reachability targets...", []string{ fmt.Sprintf("Region: %s", m.activeReachabilityRegion()), }, - m.loadReachabilityTargets(), + rm.loadReachabilityTargets(*m), ) case "enter": - if len(m.filteredReachabilityTargets) == 0 { - return m, nil - } - selected := m.filteredReachabilityTargets[m.reachabilityIdx] - m.reachabilitySource = &selected - m.reachabilityDestination = nil - m.reachabilityDestinationIP = "" - m.reachabilityFilter = "" - m.reachabilityFilterActive = false - m.reachabilityDestTypes = buildReachabilityTargetTypes(m.reachabilityTargets, true) - m.reachabilityDestTypeIdx = 0 - m.filteredReachabilityTargets = applyReachabilityTargetFilter(reachabilityDestinationCandidates(m.reachabilityTargets), m.selectedReachabilityDestinationType(), "") - m.reachabilityIdx = 0 + if len(rm.filteredTargets) == 0 { + return *m, nil + } + selected := rm.filteredTargets[rm.idx] + rm.source = &selected + rm.destination = nil + rm.destinationIP = "" + rm.filter = "" + rm.filterActive = false + rm.destTypes = buildReachabilityTargetTypes(rm.targets, true) + rm.destTypeIdx = 0 + rm.filteredTargets = applyReachabilityTargetFilter(reachabilityDestinationCandidates(rm.targets), rm.selectedDestinationType(), "") + rm.idx = 0 m.screen = screenReachabilityDestinationList } - return m, nil + return *m, nil } -func (m Model) updateReachabilityDestinationList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (rm *reachabilityModel) updateDestinationList(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() - if m.reachabilityFilterActive { - newFilter, deactivate, changed := handleFilterKey(key, m.reachabilityFilter) - m.reachabilityFilter = newFilter + if rm.filterActive { + newFilter, deactivate, changed := handleFilterKey(key, rm.filter) + rm.filter = newFilter if deactivate { - m.reachabilityFilterActive = false + rm.filterActive = false } if changed { - m.filteredReachabilityTargets = applyReachabilityTargetFilter(reachabilityDestinationCandidates(m.reachabilityTargets), m.selectedReachabilityDestinationType(), m.reachabilityFilter) - m.reachabilityIdx = 0 + rm.filteredTargets = applyReachabilityTargetFilter(reachabilityDestinationCandidates(rm.targets), rm.selectedDestinationType(), rm.filter) + rm.idx = 0 } - return m, nil + return *m, nil } switch key { case "q": m.screen = screenFeatureList case "esc": - m.filteredReachabilityTargets = applyReachabilityTargetFilter(m.reachabilityTargets, m.selectedReachabilitySourceType(), "") - m.reachabilityIdx = 0 - m.reachabilityFilter = "" - m.reachabilityFilterActive = false + rm.filteredTargets = applyReachabilityTargetFilter(rm.targets, rm.selectedSourceType(), "") + rm.idx = 0 + rm.filter = "" + rm.filterActive = false m.screen = screenReachabilitySourceList case "left", "h": - if m.reachabilityDestTypeIdx > 0 { - m.reachabilityDestTypeIdx-- - m.filteredReachabilityTargets = applyReachabilityTargetFilter(reachabilityDestinationCandidates(m.reachabilityTargets), m.selectedReachabilityDestinationType(), m.reachabilityFilter) - m.reachabilityIdx = 0 + if rm.destTypeIdx > 0 { + rm.destTypeIdx-- + rm.filteredTargets = applyReachabilityTargetFilter(reachabilityDestinationCandidates(rm.targets), rm.selectedDestinationType(), rm.filter) + rm.idx = 0 } case "right", "l", "tab": - if m.reachabilityDestTypeIdx < len(m.reachabilityDestTypes)-1 { - m.reachabilityDestTypeIdx++ - m.filteredReachabilityTargets = applyReachabilityTargetFilter(reachabilityDestinationCandidates(m.reachabilityTargets), m.selectedReachabilityDestinationType(), m.reachabilityFilter) - m.reachabilityIdx = 0 + if rm.destTypeIdx < len(rm.destTypes)-1 { + rm.destTypeIdx++ + rm.filteredTargets = applyReachabilityTargetFilter(reachabilityDestinationCandidates(rm.targets), rm.selectedDestinationType(), rm.filter) + rm.idx = 0 } case "up", "k": - m.reachabilityIdx = previousListIndex(m.reachabilityIdx, len(m.filteredReachabilityTargets)) + rm.idx = previousListIndex(rm.idx, len(rm.filteredTargets)) case "down", "j": - m.reachabilityIdx = nextListIndex(m.reachabilityIdx, len(m.filteredReachabilityTargets)) + rm.idx = nextListIndex(rm.idx, len(rm.filteredTargets)) case "/": - m.reachabilityFilterActive = true + rm.filterActive = true case "r": return m.startLoadingWithMessage( "Refreshing reachability targets...", []string{ fmt.Sprintf("Region: %s", m.activeReachabilityRegion()), }, - m.loadReachabilityTargets(), + rm.loadReachabilityTargets(*m), ) case "enter": - if len(m.filteredReachabilityTargets) == 0 { - return m, nil + if len(rm.filteredTargets) == 0 { + return *m, nil } - selected := m.filteredReachabilityTargets[m.reachabilityIdx] - m.reachabilityDestination = &selected + selected := rm.filteredTargets[rm.idx] + rm.destination = &selected if !selected.ManualIP { - m.reachabilityDestinationIP = "" + rm.destinationIP = "" } - m.reachabilityProtocolIdx = 0 - m.reachabilityPortInput = "443" - m.reachabilityConfigField = 0 + rm.protocolIdx = 0 + rm.portInput = "443" + rm.configField = 0 m.screen = screenReachabilityConfig } - return m, nil + return *m, nil } -func (m Model) updateReachabilityConfig(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (rm *reachabilityModel) updateConfig(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q": m.screen = screenFeatureList @@ -287,69 +420,69 @@ func (m Model) updateReachabilityConfig(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.screen = screenReachabilityDestinationList case "up", "k": maxField := 1 - if m.reachabilityDestination != nil && m.reachabilityDestination.ManualIP { + if rm.destination != nil && rm.destination.ManualIP { maxField = 2 } - m.reachabilityConfigField = previousListIndex(m.reachabilityConfigField, maxField+1) + rm.configField = previousListIndex(rm.configField, maxField+1) case "down", "j", "tab": maxField := 1 - if m.reachabilityDestination != nil && m.reachabilityDestination.ManualIP { + if rm.destination != nil && rm.destination.ManualIP { maxField = 2 } - m.reachabilityConfigField = nextListIndex(m.reachabilityConfigField, maxField+1) + rm.configField = nextListIndex(rm.configField, maxField+1) case "left", "h": - if m.reachabilityConfigField == 0 && m.reachabilityProtocolIdx > 0 { - m.reachabilityProtocolIdx-- + if rm.configField == 0 && rm.protocolIdx > 0 { + rm.protocolIdx-- } case "right", "l": - if m.reachabilityConfigField == 0 && m.reachabilityProtocolIdx < len(reachabilityProtocols)-1 { - m.reachabilityProtocolIdx++ + if rm.configField == 0 && rm.protocolIdx < len(reachabilityProtocols)-1 { + rm.protocolIdx++ } case "backspace": - switch m.reachabilityConfigField { + switch rm.configField { case 1: - if len(m.reachabilityPortInput) > 0 { - m.reachabilityPortInput = m.reachabilityPortInput[:len(m.reachabilityPortInput)-1] + if len(rm.portInput) > 0 { + rm.portInput = rm.portInput[:len(rm.portInput)-1] } case 2: - if len(m.reachabilityDestinationIP) > 0 { - m.reachabilityDestinationIP = m.reachabilityDestinationIP[:len(m.reachabilityDestinationIP)-1] + if len(rm.destinationIP) > 0 { + rm.destinationIP = rm.destinationIP[:len(rm.destinationIP)-1] } } case "enter": - if m.reachabilityConfigField == 0 { + if rm.configField == 0 { maxField := 1 - if m.reachabilityDestination != nil && m.reachabilityDestination.ManualIP { + if rm.destination != nil && rm.destination.ManualIP { maxField = 2 } - if m.reachabilityConfigField < maxField { - m.reachabilityConfigField++ - return m, nil + if rm.configField < maxField { + rm.configField++ + return *m, nil } } return m.startLoadingWithMessage( "Finding Network Path", - m.reachabilityLoadingDetails(), - m.runReachabilityAnalysis(), + rm.loadingDetails(*m), + rm.runAnalysis(*m), ) default: if len(msg.String()) == 1 { - switch m.reachabilityConfigField { + switch rm.configField { case 1: if msg.String()[0] >= '0' && msg.String()[0] <= '9' { - m.reachabilityPortInput += msg.String() + rm.portInput += msg.String() } case 2: if strings.ContainsRune("0123456789.", rune(msg.String()[0])) { - m.reachabilityDestinationIP += msg.String() + rm.destinationIP += msg.String() } } } } - return m, nil + return *m, nil } -func (m Model) updateReachabilityResult(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (rm *reachabilityModel) updateResult(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q": m.screen = screenFeatureList @@ -358,32 +491,32 @@ func (m Model) updateReachabilityResult(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "r": return m.startLoadingWithMessage( "Finding Network Path", - m.reachabilityLoadingDetails(), - m.runReachabilityAnalysis(), + rm.loadingDetails(*m), + rm.runAnalysis(*m), ) case "up", "k": - if m.reachabilityScrollOffset > 0 { - m.reachabilityScrollOffset-- + if rm.scrollOffset > 0 { + rm.scrollOffset-- } case "down", "j": - lines := len(m.reachabilityResultLines()) + lines := len(rm.resultLines(*m)) visible := max(m.height-8, 5) - if m.reachabilityScrollOffset < max(lines-visible, 0) { - m.reachabilityScrollOffset++ + if rm.scrollOffset < max(lines-visible, 0) { + rm.scrollOffset++ } } - return m, nil + return *m, nil } -func (m Model) viewReachabilitySourceList() string { - return m.viewReachabilityTargetList("Reachability Analyzer > Source", fmt.Sprintf("Region: %s. Supported source types: EC2 instances, Internet gateways, Network interfaces, Transit gateways, Transit gateway attachments, Virtual private gateways, VPC endpoint services, VPC endpoints, and VPC peering connections.", m.activeReachabilityRegion()), m.filteredReachabilityTargets, m.reachabilitySourceTypes, m.reachabilitySourceTypeIdx, "←/→ or tab: type • ↑/↓: navigate • /: filter • r: refresh • enter: select • esc: back • H: home") +func (rm reachabilityModel) viewSourceList(m Model) string { + return rm.viewTargetList(m, "Reachability Analyzer > Source", fmt.Sprintf("Region: %s. Supported source types: EC2 instances, Internet gateways, Network interfaces, Transit gateways, Transit gateway attachments, Virtual private gateways, VPC endpoint services, VPC endpoints, and VPC peering connections.", m.activeReachabilityRegion()), rm.filteredTargets, rm.sourceTypes, rm.sourceTypeIdx, "←/→ or tab: type • ↑/↓: navigate • /: filter • r: refresh • enter: select • esc: back • H: home") } -func (m Model) viewReachabilityDestinationList() string { - return m.viewReachabilityTargetList("Reachability Analyzer > Destination", fmt.Sprintf("Region: %s. Supported destination types: EC2 instances, Internet gateways, Network interfaces, Transit gateways, Transit gateway attachments, Virtual private gateways, VPC endpoint services, VPC endpoints, VPC peering connections, and IP addresses.", m.activeReachabilityRegion()), m.filteredReachabilityTargets, m.reachabilityDestTypes, m.reachabilityDestTypeIdx, "←/→ or tab: type • ↑/↓: navigate • /: filter • r: refresh • enter: select • esc: back • H: home") +func (rm reachabilityModel) viewDestinationList(m Model) string { + return rm.viewTargetList(m, "Reachability Analyzer > Destination", fmt.Sprintf("Region: %s. Supported destination types: EC2 instances, Internet gateways, Network interfaces, Transit gateways, Transit gateway attachments, Virtual private gateways, VPC endpoint services, VPC endpoints, VPC peering connections, and IP addresses.", m.activeReachabilityRegion()), rm.filteredTargets, rm.destTypes, rm.destTypeIdx, "←/→ or tab: type • ↑/↓: navigate • /: filter • r: refresh • enter: select • esc: back • H: home") } -func (m Model) viewReachabilityRegionList() string { +func (rm reachabilityModel) viewRegionList(m Model) string { var b strings.Builder var panel strings.Builder b.WriteString(m.renderStatusBar()) @@ -391,28 +524,28 @@ func (m Model) viewReachabilityRegionList() string { b.WriteString("\n") b.WriteString(dimStyle.Render("Start with the region you want to inspect. The current context region is preselected.")) b.WriteString("\n") - if m.reachabilityRegionFiltering { - b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.reachabilityRegionFilter))) - } else if m.reachabilityRegionFilter != "" { - b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", m.reachabilityRegionFilter))) + if rm.regionFiltering { + b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", rm.regionFilter))) + } else if rm.regionFilter != "" { + b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", rm.regionFilter))) } b.WriteString("\n\n") - if len(m.filteredReachabilityRegions) == 0 { + if len(rm.filteredRegions) == 0 { panel.WriteString(dimStyle.Render(" No matching regions")) panel.WriteString("\n") } else { visibleLines := max(m.height-12, 5) start := 0 - if m.reachabilityRegionIdx >= visibleLines { - start = m.reachabilityRegionIdx - visibleLines + 1 + if rm.regionIdx >= visibleLines { + start = rm.regionIdx - visibleLines + 1 } - end := min(start+visibleLines, len(m.filteredReachabilityRegions)) + end := min(start+visibleLines, len(rm.filteredRegions)) for i := start; i < end; i++ { - region := m.filteredReachabilityRegions[i] + region := rm.filteredRegions[i] cursor := " " style := normalStyle - if i == m.reachabilityRegionIdx { + if i == rm.regionIdx { cursor = "> " style = selectedStyle } @@ -431,7 +564,7 @@ func (m Model) viewReachabilityRegionList() string { return b.String() } -func (m Model) viewReachabilityTargetList(title, subtitle string, items []awsservice.ReachabilityTarget, typeOptions []string, typeIdx int, footer string) string { +func (rm reachabilityModel) viewTargetList(m Model, title, subtitle string, items []awsservice.ReachabilityTarget, typeOptions []string, typeIdx int, footer string) string { var b strings.Builder var panel strings.Builder b.WriteString(m.renderStatusBar()) @@ -440,13 +573,13 @@ func (m Model) viewReachabilityTargetList(title, subtitle string, items []awsser b.WriteString(dimStyle.Render(subtitle)) b.WriteString("\n") if len(typeOptions) > 0 { - b.WriteString(m.renderReachabilityTypeSelector(typeOptions, typeIdx)) + b.WriteString(rm.renderTypeSelector(typeOptions, typeIdx)) b.WriteString("\n") } - if m.reachabilityFilterActive { - b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.reachabilityFilter))) - } else if m.reachabilityFilter != "" { - b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", m.reachabilityFilter))) + if rm.filterActive { + b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", rm.filter))) + } else if rm.filter != "" { + b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", rm.filter))) } b.WriteString("\n\n") @@ -456,8 +589,8 @@ func (m Model) viewReachabilityTargetList(title, subtitle string, items []awsser } else { visibleLines := max(m.height-12, 5) start := 0 - if m.reachabilityIdx >= visibleLines { - start = m.reachabilityIdx - visibleLines + 1 + if rm.idx >= visibleLines { + start = rm.idx - visibleLines + 1 } end := min(start+visibleLines, len(items)) @@ -465,7 +598,7 @@ func (m Model) viewReachabilityTargetList(title, subtitle string, items []awsser item := items[i] cursor := " " style := normalStyle - if i == m.reachabilityIdx { + if i == rm.idx { cursor = "> " style = selectedStyle } @@ -482,7 +615,7 @@ func (m Model) viewReachabilityTargetList(title, subtitle string, items []awsser return b.String() } -func (m Model) viewReachabilityConfig() string { +func (rm reachabilityModel) viewConfig(m Model) string { var b strings.Builder b.WriteString(m.renderStatusBar()) b.WriteString(titleStyle.Render("Reachability Analyzer > Analysis Settings")) @@ -491,12 +624,12 @@ func (m Model) viewReachabilityConfig() string { b.WriteString("\n") source := "" - if m.reachabilitySource != nil { - source = m.reachabilitySource.DisplayTitle() + if rm.source != nil { + source = rm.source.DisplayTitle() } destination := "" - if m.reachabilityDestination != nil { - destination = m.reachabilityDestination.DisplayTitle() + if rm.destination != nil { + destination = rm.destination.DisplayTitle() } b.WriteString(normalStyle.Render(" Source : " + source)) @@ -504,28 +637,28 @@ func (m Model) viewReachabilityConfig() string { b.WriteString(normalStyle.Render(" Destination : " + destination)) b.WriteString("\n\n") - protocol := reachabilityProtocols[m.reachabilityProtocolIdx] - if m.reachabilityConfigField == 0 { + protocol := reachabilityProtocols[rm.protocolIdx] + if rm.configField == 0 { b.WriteString(selectedStyle.Render(" Protocol : " + protocol)) } else { b.WriteString(normalStyle.Render(" Protocol : " + protocol)) } b.WriteString("\n") - portValue := m.reachabilityPortInput + portValue := rm.portInput if portValue == "" { portValue = "443" } - if m.reachabilityConfigField == 1 { + if rm.configField == 1 { b.WriteString(selectedStyle.Render(" Dest Port : " + portValue + "▏")) } else { b.WriteString(normalStyle.Render(" Dest Port : " + portValue)) } b.WriteString("\n") - if m.reachabilityDestination != nil && m.reachabilityDestination.ManualIP { - ipValue := m.reachabilityDestinationIP - if m.reachabilityConfigField == 2 { + if rm.destination != nil && rm.destination.ManualIP { + ipValue := rm.destinationIP + if rm.configField == 2 { b.WriteString(selectedStyle.Render(" Dest IPv4 : " + ipValue + "▏")) } else { b.WriteString(normalStyle.Render(" Dest IPv4 : " + ipValue)) @@ -540,15 +673,15 @@ func (m Model) viewReachabilityConfig() string { return b.String() } -func (m Model) viewReachabilityResult() string { +func (rm reachabilityModel) viewResult(m Model) string { var b strings.Builder b.WriteString(m.renderStatusBar()) b.WriteString(titleStyle.Render("Reachability Analyzer > Result")) b.WriteString("\n\n") - lines := m.reachabilityResultLines() + lines := rm.resultLines(m) visibleLines := max(m.height-8, 5) - start := min(m.reachabilityScrollOffset, max(len(lines)-visibleLines, 0)) + start := min(rm.scrollOffset, max(len(lines)-visibleLines, 0)) end := min(start+visibleLines, len(lines)) for _, line := range lines[start:end] { b.WriteString(line) @@ -561,12 +694,12 @@ func (m Model) viewReachabilityResult() string { return b.String() } -func (m Model) reachabilityResultLines() []string { - if m.reachabilityResult == nil { +func (rm reachabilityModel) resultLines(m Model) []string { + if rm.result == nil { return []string{dimStyle.Render("No analysis result")} } - r := m.reachabilityResult + r := rm.result lines := make([]string, 0, 64) status := "Not reachable" statusStyle := errorStyle @@ -686,21 +819,21 @@ func applyReachabilityTargetFilter(items []awsservice.ReachabilityTarget, target return applyFilter(filtered, query) } -func (m Model) selectedReachabilitySourceType() string { - if len(m.reachabilitySourceTypes) == 0 { +func (rm reachabilityModel) selectedSourceType() string { + if len(rm.sourceTypes) == 0 { return "" } - return m.reachabilitySourceTypes[m.reachabilitySourceTypeIdx] + return rm.sourceTypes[rm.sourceTypeIdx] } -func (m Model) selectedReachabilityDestinationType() string { - if len(m.reachabilityDestTypes) == 0 { +func (rm reachabilityModel) selectedDestinationType() string { + if len(rm.destTypes) == 0 { return "" } - return m.reachabilityDestTypes[m.reachabilityDestTypeIdx] + return rm.destTypes[rm.destTypeIdx] } -func (m Model) renderReachabilityTypeSelector(options []string, selected int) string { +func (rm reachabilityModel) renderTypeSelector(options []string, selected int) string { var parts []string for i, option := range options { label := "[" + option + "]" @@ -714,10 +847,7 @@ func (m Model) renderReachabilityTypeSelector(options []string, selected int) st } func (m Model) activeReachabilityRegion() string { - if strings.TrimSpace(m.reachabilityRegion) != "" { - return m.reachabilityRegion - } - return m.cfg.Region + return m.reachability.activeRegion(m) } func availableReachabilityRegions(current string) []string { @@ -767,27 +897,27 @@ func indexOfString(items []string, target string) int { return -1 } -func (m Model) reachabilityLoadingDetails() []string { +func (rm reachabilityModel) loadingDetails(m Model) []string { source := "source pending" - if m.reachabilitySource != nil { - source = m.reachabilitySource.DisplayTitle() + if rm.source != nil { + source = rm.source.DisplayTitle() } destination := "destination pending" - if strings.TrimSpace(m.reachabilityDestinationIP) != "" { - destination = strings.TrimSpace(m.reachabilityDestinationIP) - } else if m.reachabilityDestination != nil { - destination = m.reachabilityDestination.DisplayTitle() + if strings.TrimSpace(rm.destinationIP) != "" { + destination = strings.TrimSpace(rm.destinationIP) + } else if rm.destination != nil { + destination = rm.destination.DisplayTitle() } protocol := "" - if m.reachabilityProtocolIdx >= 0 && m.reachabilityProtocolIdx < len(reachabilityProtocols) { - protocol = reachabilityProtocols[m.reachabilityProtocolIdx] + if rm.protocolIdx >= 0 && rm.protocolIdx < len(reachabilityProtocols) { + protocol = reachabilityProtocols[rm.protocolIdx] } intent := protocol - if strings.TrimSpace(m.reachabilityPortInput) != "" { - intent = fmt.Sprintf("%s/%s", protocol, strings.TrimSpace(m.reachabilityPortInput)) + if strings.TrimSpace(rm.portInput) != "" { + intent = fmt.Sprintf("%s/%s", protocol, strings.TrimSpace(rm.portInput)) } source = truncateReachabilityLoadingLabel(source, m.width) diff --git a/internal/app/screen_vpc.go b/internal/app/screen_vpc.go index 41c92ab..d92c201 100644 --- a/internal/app/screen_vpc.go +++ b/internal/app/screen_vpc.go @@ -10,100 +10,192 @@ import ( awsservice "unic/internal/services/aws" ) -func (m Model) updateVPCList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +type vpcModel struct { + vpcs []awsservice.VPC + filteredVPCs []awsservice.VPC + vpcIdx int + subnets []awsservice.Subnet + filteredSubnets []awsservice.Subnet + subnetIdx int + selectedVPC *awsservice.VPC + selectedSubnet *awsservice.Subnet + availableIPs []string + filteredIPs []string + ipScrollOffset int +} + +func newVPCModel() vpcModel { + return vpcModel{} +} + +func (vm *vpcModel) Start(m *Model) (tea.Model, tea.Cmd) { + return m.startLoading(vm.loadVPCs(*m)) +} + +func (vm *vpcModel) HandleMessage(m *Model, msg tea.Msg) (tea.Model, tea.Cmd, bool) { + switch msg := msg.(type) { + case vpcsLoadedMsg: + vm.vpcs = msg.vpcs + m.resetFilter(filterVPCs) + vm.vpcIdx = 0 + m.screen = screenVPCList + return *m, nil, true + case subnetsLoadedMsg: + vm.subnets = msg.subnets + m.resetFilter(filterSubnets) + vm.subnetIdx = 0 + m.screen = screenSubnetList + return *m, nil, true + case availableIPsLoadedMsg: + vm.availableIPs = msg.ips + m.resetFilter(filterSubnetIPs) + m.screen = screenSubnetDetail + return *m, nil, true + } + return *m, nil, false +} + +func (vm *vpcModel) HandleKey(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { + switch m.screen { + case screenVPCList: + newM, cmd := vm.updateVPCList(m, msg) + return newM, cmd, true + case screenSubnetList: + newM, cmd := vm.updateSubnetList(m, msg) + return newM, cmd, true + case screenSubnetDetail: + newM, cmd := vm.updateSubnetDetail(m, msg) + return newM, cmd, true + default: + return *m, nil, false + } +} + +func (vm vpcModel) View(m Model) (string, bool) { + switch m.screen { + case screenVPCList: + return vm.viewVPCList(m), true + case screenSubnetList: + return vm.viewSubnetList(m), true + case screenSubnetDetail: + return vm.viewSubnetDetail(m), true + default: + return "", false + } +} + +func (vm *vpcModel) ApplyFilter(m *Model, target filterTarget) bool { + switch target { + case filterVPCs: + vm.filteredVPCs = applyFilter(vm.vpcs, m.filterValue(target)) + vm.vpcIdx = 0 + return true + case filterSubnets: + vm.filteredSubnets = applyFilter(vm.subnets, m.filterValue(target)) + vm.subnetIdx = 0 + return true + case filterSubnetIPs: + vm.applyIPFilter(m) + return true + default: + return false + } +} + +func (vm *vpcModel) updateVPCList(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { if cmd, handled := m.updateSharedFilter(msg, filterVPCs); handled { - return m, cmd + return *m, cmd } switch msg.String() { case "q", "esc": m.screen = screenFeatureList - m.vpcIdx = 0 + vm.vpcIdx = 0 m.resetFilter(filterVPCs) case "up", "k": - m.vpcIdx = previousListIndex(m.vpcIdx, len(m.filteredVPCs)) + vm.vpcIdx = previousListIndex(vm.vpcIdx, len(vm.filteredVPCs)) case "down", "j": - m.vpcIdx = nextListIndex(m.vpcIdx, len(m.filteredVPCs)) + vm.vpcIdx = nextListIndex(vm.vpcIdx, len(vm.filteredVPCs)) case "/": - return m, m.activateFilter(filterVPCs) + return *m, m.activateFilter(filterVPCs) case "enter": - if len(m.filteredVPCs) > 0 && m.vpcIdx < len(m.filteredVPCs) { - selected := m.filteredVPCs[m.vpcIdx] - m.selectedVPC = &selected - return m.startLoading(m.loadSubnets(selected.VPCID)) + if len(vm.filteredVPCs) > 0 && vm.vpcIdx < len(vm.filteredVPCs) { + selected := vm.filteredVPCs[vm.vpcIdx] + vm.selectedVPC = &selected + return m.startLoading(vm.loadSubnets(*m, selected.VPCID)) } } - return m, nil + return *m, nil } -func (m Model) updateSubnetList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (vm *vpcModel) updateSubnetList(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { if cmd, handled := m.updateSharedFilter(msg, filterSubnets); handled { - return m, cmd + return *m, cmd } switch msg.String() { case "q", "esc": m.screen = screenVPCList - m.subnetIdx = 0 + vm.subnetIdx = 0 m.resetFilter(filterSubnets) case "up", "k": - m.subnetIdx = previousListIndex(m.subnetIdx, len(m.filteredSubnets)) + vm.subnetIdx = previousListIndex(vm.subnetIdx, len(vm.filteredSubnets)) case "down", "j": - m.subnetIdx = nextListIndex(m.subnetIdx, len(m.filteredSubnets)) + vm.subnetIdx = nextListIndex(vm.subnetIdx, len(vm.filteredSubnets)) case "/": - return m, m.activateFilter(filterSubnets) + return *m, m.activateFilter(filterSubnets) case "enter": - if len(m.filteredSubnets) > 0 && m.subnetIdx < len(m.filteredSubnets) { - selected := m.filteredSubnets[m.subnetIdx] - m.selectedSubnet = &selected - return m.startLoading(m.loadAvailableIPs(selected)) + if len(vm.filteredSubnets) > 0 && vm.subnetIdx < len(vm.filteredSubnets) { + selected := vm.filteredSubnets[vm.subnetIdx] + vm.selectedSubnet = &selected + return m.startLoading(vm.loadAvailableIPs(*m, selected)) } } - return m, nil + return *m, nil } -func (m Model) updateSubnetDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (vm *vpcModel) updateSubnetDetail(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() if cmd, handled := m.updateSharedFilter(msg, filterSubnetIPs); handled { - return m, cmd + return *m, cmd } switch key { case "q", "esc": m.screen = screenSubnetList case "up", "k": - if m.ipScrollOffset > 0 { - m.ipScrollOffset-- + if vm.ipScrollOffset > 0 { + vm.ipScrollOffset-- } case "down", "j": visibleLines := max(m.height-12, 5) - if m.ipScrollOffset < len(m.filteredIPs)-visibleLines { - m.ipScrollOffset++ + if vm.ipScrollOffset < len(vm.filteredIPs)-visibleLines { + vm.ipScrollOffset++ } case "/": - return m, m.activateFilter(filterSubnetIPs) + return *m, m.activateFilter(filterSubnetIPs) } - return m, nil + return *m, nil } -func (m *Model) applyIPFilter() { +func (vm *vpcModel) applyIPFilter(m *Model) { query := m.filterValue(filterSubnetIPs) if query == "" { - m.filteredIPs = m.availableIPs + vm.filteredIPs = vm.availableIPs } else { var result []string - for _, ip := range m.availableIPs { + for _, ip := range vm.availableIPs { if strings.Contains(ip, query) { result = append(result, ip) } } - m.filteredIPs = result + vm.filteredIPs = result } - m.ipScrollOffset = 0 + vm.ipScrollOffset = 0 } -func (m Model) loadVPCs() tea.Cmd { +func (vm vpcModel) loadVPCs(m Model) tea.Cmd { return func() tea.Msg { ctx := context.Background() repo, err := awsservice.NewAwsRepository(ctx, m.cfg) @@ -123,7 +215,7 @@ func (m Model) loadVPCs() tea.Cmd { } } -func (m Model) loadSubnets(vpcID string) tea.Cmd { +func (vm vpcModel) loadSubnets(m Model, vpcID string) tea.Cmd { return func() tea.Msg { ctx := context.Background() repo := m.awsRepo @@ -146,7 +238,7 @@ func (m Model) loadSubnets(vpcID string) tea.Cmd { } } -func (m Model) loadAvailableIPs(subnet awsservice.Subnet) tea.Cmd { +func (vm vpcModel) loadAvailableIPs(m Model, subnet awsservice.Subnet) tea.Cmd { return func() tea.Msg { ctx := context.Background() repo := m.awsRepo @@ -165,7 +257,7 @@ func (m Model) loadAvailableIPs(subnet awsservice.Subnet) tea.Cmd { } } -func (m Model) viewVPCList() string { +func (vm vpcModel) viewVPCList(m Model) string { var b strings.Builder var panel strings.Builder b.WriteString(m.renderStatusBar()) @@ -175,22 +267,22 @@ func (m Model) viewVPCList() string { b.WriteString(m.renderFilterValue(filterVPCs)) b.WriteString("\n\n") - if len(m.filteredVPCs) == 0 { + if len(vm.filteredVPCs) == 0 { panel.WriteString(dimStyle.Render(" No matching VPCs")) panel.WriteString("\n") } else { visibleLines := max(m.height-11, 5) start := 0 - if m.vpcIdx >= visibleLines { - start = m.vpcIdx - visibleLines + 1 + if vm.vpcIdx >= visibleLines { + start = vm.vpcIdx - visibleLines + 1 } - end := min(start+visibleLines, len(m.filteredVPCs)) + end := min(start+visibleLines, len(vm.filteredVPCs)) for i := start; i < end; i++ { - vpc := m.filteredVPCs[i] + vpc := vm.filteredVPCs[i] cursor := " " style := normalStyle - if i == m.vpcIdx { + if i == vm.vpcIdx { cursor = "> " style = selectedStyle } @@ -198,7 +290,7 @@ func (m Model) viewVPCList() string { panel.WriteString("\n") } panel.WriteString("\n") - panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d VPCs", len(m.filteredVPCs), len(m.vpcs)))) + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d VPCs", len(vm.filteredVPCs), len(vm.vpcs)))) } b.WriteString(m.renderListPanel(panel.String())) @@ -207,13 +299,13 @@ func (m Model) viewVPCList() string { return b.String() } -func (m Model) viewSubnetList() string { +func (vm vpcModel) viewSubnetList(m Model) string { var b strings.Builder var panel strings.Builder b.WriteString(m.renderStatusBar()) vpcName := "" - if m.selectedVPC != nil { - vpcName = fmt.Sprintf(" (%s)", m.selectedVPC.Name) + if vm.selectedVPC != nil { + vpcName = fmt.Sprintf(" (%s)", vm.selectedVPC.Name) } b.WriteString(titleStyle.Render(fmt.Sprintf("Subnets%s", vpcName))) b.WriteString("\n") @@ -221,22 +313,22 @@ func (m Model) viewSubnetList() string { b.WriteString(m.renderFilterValue(filterSubnets)) b.WriteString("\n\n") - if len(m.filteredSubnets) == 0 { + if len(vm.filteredSubnets) == 0 { panel.WriteString(dimStyle.Render(" No matching subnets")) panel.WriteString("\n") } else { visibleLines := max(m.height-11, 5) start := 0 - if m.subnetIdx >= visibleLines { - start = m.subnetIdx - visibleLines + 1 + if vm.subnetIdx >= visibleLines { + start = vm.subnetIdx - visibleLines + 1 } - end := min(start+visibleLines, len(m.filteredSubnets)) + end := min(start+visibleLines, len(vm.filteredSubnets)) for i := start; i < end; i++ { - s := m.filteredSubnets[i] + s := vm.filteredSubnets[i] cursor := " " style := normalStyle - if i == m.subnetIdx { + if i == vm.subnetIdx { cursor = "> " style = selectedStyle } @@ -244,7 +336,7 @@ func (m Model) viewSubnetList() string { panel.WriteString("\n") } panel.WriteString("\n") - panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d subnets", len(m.filteredSubnets), len(m.subnets)))) + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d subnets", len(vm.filteredSubnets), len(vm.subnets)))) } b.WriteString(m.renderListPanel(panel.String())) @@ -253,11 +345,11 @@ func (m Model) viewSubnetList() string { return b.String() } -func (m Model) viewSubnetDetail() string { - if m.selectedSubnet == nil { +func (vm vpcModel) viewSubnetDetail(m Model) string { + if vm.selectedSubnet == nil { return "" } - s := m.selectedSubnet + s := vm.selectedSubnet var b strings.Builder b.WriteString(m.renderStatusBar()) b.WriteString(titleStyle.Render("Subnet Detail")) @@ -270,26 +362,26 @@ func (m Model) viewSubnetDetail() string { b.WriteString("\n") b.WriteString(renderDetailLine("AZ", normalStyle.Render(s.AvailabilityZone))) b.WriteString("\n") - b.WriteString(renderDetailLine("Available IPs", normalStyle.Render(fmt.Sprintf("%d", len(m.availableIPs))))) + b.WriteString(renderDetailLine("Available IPs", normalStyle.Render(fmt.Sprintf("%d", len(vm.availableIPs))))) b.WriteString("\n\n") b.WriteString(m.renderFilterValue(filterSubnetIPs)) b.WriteString("\n") - if len(m.filteredIPs) == 0 { + if len(vm.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)) + start := vm.ipScrollOffset + end := min(start+visibleLines, len(vm.filteredIPs)) - for _, ip := range m.filteredIPs[start:end] { + for _, ip := range vm.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(dimStyle.Render(fmt.Sprintf(" %d-%d of %d IPs", start+1, end, len(vm.filteredIPs)))) } b.WriteString("\n") diff --git a/internal/app/screen_vpc_test.go b/internal/app/screen_vpc_test.go index 2ab117b..51f8894 100644 --- a/internal/app/screen_vpc_test.go +++ b/internal/app/screen_vpc_test.go @@ -14,7 +14,7 @@ func TestVPCListSharedFilterUsesFuzzyMatching(t *testing.T) { m.width = 80 m.height = 20 - updated, _, handled := m.handleEC2VPCMsg(vpcsLoadedMsg{vpcs: []awsservice.VPC{ + updated, _, handled := m.vpc.HandleMessage(&m, vpcsLoadedMsg{vpcs: []awsservice.VPC{ {VPCID: "vpc-111", Name: "dev-core", CIDR: "10.0.0.0/16"}, {VPCID: "vpc-222", Name: "prod-core", CIDR: "10.1.0.0/16"}, }}) @@ -33,14 +33,14 @@ func TestVPCListSharedFilterUsesFuzzyMatching(t *testing.T) { if !model.isFiltering(filterVPCs) { t.Fatal("expected VPC filter to be active") } - if got := len(model.filteredVPCs); got != 1 { + if got := len(model.vpc.filteredVPCs); got != 1 { t.Fatalf("expected 1 filtered VPC, got %d", got) } - if got := model.filteredVPCs[0].Name; got != "prod-core" { + if got := model.vpc.filteredVPCs[0].Name; got != "prod-core" { t.Fatalf("expected filtered VPC prod-core, got %q", got) } - view := model.viewVPCList() + view := model.vpc.viewVPCList(model) if !strings.Contains(stripANSI(view), "Filter: prd") { t.Fatalf("expected view to show VPC filter value, got %q", stripANSI(view)) } @@ -53,9 +53,9 @@ func TestSubnetListSharedFilterDrivesSelection(t *testing.T) { m := New(testConfig(), "", "dev") m.width = 80 m.height = 20 - m.selectedVPC = &awsservice.VPC{Name: "prod-core", VPCID: "vpc-222"} + m.vpc.selectedVPC = &awsservice.VPC{Name: "prod-core", VPCID: "vpc-222"} - updated, _, handled := m.handleEC2VPCMsg(subnetsLoadedMsg{subnets: []awsservice.Subnet{ + updated, _, handled := m.vpc.HandleMessage(&m, subnetsLoadedMsg{subnets: []awsservice.Subnet{ {SubnetID: "subnet-111", Name: "private-a", CIDR: "10.1.1.0/24", AvailabilityZone: "us-west-2a"}, {SubnetID: "subnet-222", Name: "public-b", CIDR: "10.1.2.0/24", AvailabilityZone: "us-west-2b"}, }}) @@ -71,10 +71,10 @@ func TestSubnetListSharedFilterDrivesSelection(t *testing.T) { model = updated.(Model) } - if got := len(model.filteredSubnets); got != 1 { + if got := len(model.vpc.filteredSubnets); got != 1 { t.Fatalf("expected 1 filtered subnet, got %d", got) } - if got := model.filteredSubnets[0].SubnetID; got != "subnet-222" { + if got := model.vpc.filteredSubnets[0].SubnetID; got != "subnet-222" { t.Fatalf("expected subnet-222 after filtering, got %q", got) } @@ -89,8 +89,8 @@ func TestSubnetListSharedFilterDrivesSelection(t *testing.T) { if cmd == nil { t.Fatal("expected subnet detail load command on enter") } - if model.selectedSubnet == nil || model.selectedSubnet.SubnetID != "subnet-222" { - t.Fatalf("expected enter to select filtered subnet, got %+v", model.selectedSubnet) + if model.vpc.selectedSubnet == nil || model.vpc.selectedSubnet.SubnetID != "subnet-222" { + t.Fatalf("expected enter to select filtered subnet, got %+v", model.vpc.selectedSubnet) } if model.screen != screenLoading { t.Fatalf("expected loading screen after selecting subnet, got %v", model.screen)