diff --git a/.changeset/faster-view-switching.md b/.changeset/faster-view-switching.md new file mode 100644 index 0000000..a9cf42e --- /dev/null +++ b/.changeset/faster-view-switching.md @@ -0,0 +1,5 @@ +--- +"seamless-glance": patch +--- + +Make startup and view switching faster. Switching views no longer refetches the whole account overview (about ten service fetches) when it is still fresh, and when the overview is needed it now loads alongside the view's data instead of ahead of it. The header keeps showing the account during a refresh instead of blanking. Startup reads cost data from the local cache only, so the first frame no longer waits on Cost Explorer, and the overview's Lambda count no longer makes one tag lookup per function. Refreshing Findings no longer fetches every service twice: the account overview is now built from the inventories Findings already loads, with only the caller identity and ECS fetched on top. diff --git a/docs/architecture.md b/docs/architecture.md index abd7d80..0f673e8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -60,7 +60,7 @@ This layer should remain primarily factual. As the product shifts toward triage, Representative files: -- [`src/aws/account.rs`](src/aws/account.rs): account overview fan-out +- [`src/aws/account.rs`](src/aws/account.rs): account overview fan-out, plus `assemble_overview` and the summary builders that derive the overview from already-fetched inventories - [`src/aws/clients.rs`](src/aws/clients.rs): shared SDK client bundle, `build_sdk_config` (the single region/profile-aware config builder), and `clients_for_region`, the one definition global fan-outs use to build a per-region bundle. Every SDK config in the app is built here, so it is also where the retry and timeout policy lives: adaptive retry (client-side rate limiting that backs off when AWS throttles) plus connect, per-attempt, and overall operation timeouts, which matter because the app fans out across every enabled region and issues a describe per resource - [`src/aws/profiles.rs`](src/aws/profiles.rs): discovers AWS profile names from the shared config and credentials files for the in-app picker - [`src/aws/cost.rs`](src/aws/cost.rs): cost explorer queries for budget, forecast, trailing spend, and usage-aware service cost insight @@ -119,7 +119,7 @@ At startup, `main`: - fetches enabled AWS regions using the resolved profile; if `ec2:DescribeRegions` fails or returns nothing, it falls back to the documented `regions::FALLBACK_REGIONS` list and raises a visible warning so the operator knows global views may be incomplete - creates `AwsClients` for the selected region and profile - constructs `App` -- preloads cost data and usage-aware service cost insight +- loads cost data and usage-aware service cost insight from the on-disk cache only; it never calls Cost Explorer before the first frame, since the cost views force a live fetch when entered - triggers the first refresh ### Refresh Flow @@ -130,7 +130,7 @@ The AWS fetches run off the draw thread so the UI stays responsive and the per-s 2. spawns a worker task on a throwaway clone of the account context (`AwsClients` is cheap to clone; the SDK clients are `Arc`-backed) 3. stores the receiving end of an `mpsc` channel on the app -The worker fetches account overview first, then the active view's service data, sending a `RefreshUpdate` per service, then `Done`. For the multi-service views (Findings and Cost Savings) the independent fetches run concurrently via `tokio::join!`, so refresh latency is the slowest single fetch rather than the sum of ~11. Target groups and load balancers are fetched as one unit (health folding needs both) that joins the concurrent set. Each frame, `App::drain_refresh_updates` applies whatever has arrived (so `refresh_phase` advances and views populate) without ever blocking the loop. On `Done` it rebuilds findings and cost savings, updates `last_refresh`, and clears the refreshing flag. +The worker fetches the active view's service data, sending a `RefreshUpdate` per service, then `Done`. When the account overview is needed it runs on its own task alongside the view's fetches rather than ahead of them, and `Done` waits for it because findings gate on the overview. Findings is the exception: it already fetches nearly every service the overview summarizes, so it fetches only the caller identity and ECS on top and assembles the overview from its own inventories (`aws::account::assemble_overview` plus the per-service summary builders there), rather than fetching each service twice. Navigation (`on_view_enter`) reuses a fresh overview instead of refetching it, since the overview alone is about ten service fetches; a manual refresh or a profile or region switch always refetches it. A same-context refresh keeps the previous overview on screen until the new one lands; only a profile or region change clears it. For the multi-service views (Findings and Cost Savings) the independent fetches run concurrently via `tokio::join!`, so refresh latency is the slowest single fetch rather than the sum of ~11. Target groups and load balancers are fetched as one unit (health folding needs both) that joins the concurrent set. Each frame, `App::drain_refresh_updates` applies whatever has arrived (so `refresh_phase` advances and views populate) without ever blocking the loop. On `Done` it rebuilds findings and cost savings, updates `last_refresh`, and clears the refreshing flag. Step 1 matters because a single-service refresh only fetches its own service but `rebuild_findings` reads several service inventories. Tracking the context the data was fetched under (`data_context`) and clearing on change ensures findings are never built from a prior region or profile's data and mislabeled with the current region. @@ -190,7 +190,7 @@ The codebase uses a mix of strategies: - list fetchers page through the full result set (`.into_paginator().items().send()`, or a manual next-token loop where the SDK has no generated paginator, such as `apigatewayv2::get_apis`), so large accounts are not silently truncated to the first page - where a fetcher needs a follow-up describe per resource (target-group health, SQS queue attributes, ECR images and lifecycle policies), those calls run concurrently but bounded by `aws::DESCRIBE_CONCURRENCY` rather than one round trip at a time, so large accounts stay fast without amplifying throttling. A failed per-resource describe drops that row, as before - cost fetchers return `(data, ServiceStatus)` too; `refresh_cost_data` composes a single `cost_status`, the cost overview and cost savings views guard on it, and only a successful fetch is written to the on-disk cost cache so a denied fetch is not persisted as a misleading $0 overview -- account overview fans out with concurrent calls and composes summary status +- account overview fans out with concurrent calls and composes summary status. It needs counts, not detail, so it skips per-resource follow-ups it does not use (Lambda counts skip the per-function `ListTags`) - SDK errors are classified centrally by `ServiceStatus::from_sdk_error`, which inspects the error code via `ProvideErrorMetadata` (recognizing `AccessDenied`, `AccessDeniedException`, `UnauthorizedOperation`, and related authz codes) rather than substring-matching the display string Contributors adding a new list view should follow the same shape: carry a `ServiceStatus` out of the fetcher, store it alongside the data, and call `render_unavailable` at the top of the view so AWS failures never render as misleading empty-state success UIs. diff --git a/src/app/findings.rs b/src/app/findings.rs index 37eac17..24a63df 100644 --- a/src/app/findings.rs +++ b/src/app/findings.rs @@ -1241,14 +1241,14 @@ pub fn build_findings(ctx: &FindingContext) -> Vec { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::models::cloudwatch::CloudWatchSummary; use crate::models::rds::RdsSummary; use crate::models::secrets::SecretsSummary; use crate::models::service_status::ServiceStatus; - fn overview() -> AccountOverview { + pub(crate) fn overview() -> AccountOverview { AccountOverview { account_id: "123456789012".into(), identity_kind: "user".into(), diff --git a/src/app/mod.rs b/src/app/mod.rs index 1ceda1a..60b2dea 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -475,7 +475,7 @@ impl App { // re-hit AWS on every view switch. A manual refresh (`r`) and profile or // region switches call `trigger_refresh` directly and always refetch. if !self.active_view_is_fresh() { - self.trigger_refresh(); + self.start_refresh(false); } } @@ -905,7 +905,16 @@ impl App { self.cost_savings_opportunities = opportunities; } + /// Refetch everything the active view shows, including the account overview. + /// Used for a manual refresh and for profile or region switches. pub fn trigger_refresh(&mut self) { + self.start_refresh(true); + } + + /// `force_overview` refetches the account overview even when it is still + /// fresh. Navigation passes `false`: the overview is ten service fetches, and + /// redoing it on every view switch is most of what made switching slow. + fn start_refresh(&mut self, force_overview: bool) { let want = ( self.current_profile.clone(), self.current_region_label(), @@ -926,9 +935,15 @@ impl App { self.data_context = Some(data_context); } + let fetch_overview = + force_overview || !self.inventory_is_fresh(refresh::InventoryKind::AccountOverview); + self.is_refreshing = true; - self.account_overview = None; - self.refresh_phase = RefreshPhase::Overview; + self.refresh_phase = if fetch_overview { + RefreshPhase::Overview + } else { + RefreshPhase::Services(Vec::new()) + }; self.in_flight_refresh = Some(want); // Run the fetches on a throwaway clone of the account context so the @@ -937,13 +952,18 @@ impl App { let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); self.refresh_rx = Some(rx); + tokio::spawn(self.refresh_worker().stream_refresh(tx, fetch_overview)); + } + + /// A throwaway copy of the account context (clients, regions, profile, and + /// view) for a refresh task to own. + pub(crate) fn refresh_worker(&self) -> App { let mut worker = App::new(self.aws.clone()); worker.regions = self.regions.clone(); worker.current_region_index = self.current_region_index; worker.current_profile = self.current_profile.clone(); worker.active_view = self.active_view; - - tokio::spawn(worker.stream_refresh(tx)); + worker } pub async fn next_region(&mut self) { @@ -990,6 +1010,7 @@ impl App { /// with the new region. The refresh worker refetches the fresh data. fn clear_service_data(&mut self) { self.inventory_fetched_at.clear(); + self.account_overview = None; self.ec2_instances.clear(); self.lambda_functions.clear(); self.apigateway_apis.clear(); @@ -1020,33 +1041,40 @@ impl App { self.cloudwatch_summary.status = not_loaded; } - pub async fn load_cost_data(&mut self) { - self.refresh_cost_data(false).await; - } - - pub async fn refresh_cost_data(&mut self, force_refresh: bool) { + /// Load cost data from the on-disk cache only. The cost views force a live + /// fetch when entered, so startup has no reason to wait on Cost Explorer + /// before the first frame is drawn. + pub fn load_cached_cost_data(&mut self) { let profile = self.current_profile.clone(); let region = self.current_region_label().to_string(); - if !force_refresh { - if let Some(cache) = load_if_fresh(profile.as_deref(), ®ion) { - let cache_has_usage_insight = - !cache.service_cost_insights.is_empty() || cache.service_costs.is_empty(); - - if cache_has_usage_insight { - self.budget = cache.budget; - self.monthly_costs = cache.monthly_costs; - self.service_costs = cache.service_costs; - self.service_cost_insights = cache.service_cost_insights; - // Only successful fetches are cached, so a cache hit is Ok data. - self.cost_status = ServiceStatus::Ok; - self.cost_loaded = true; - self.rebuild_cost_savings(); - return; - } - } + let Some(cache) = load_if_fresh(profile.as_deref(), ®ion) else { + return; + }; + + // A cache written before usage insights existed would render an empty + // breakdown; leave it for the cost views' live fetch to replace. + let cache_has_usage_insight = + !cache.service_cost_insights.is_empty() || cache.service_costs.is_empty(); + if !cache_has_usage_insight { + return; } + self.budget = cache.budget; + self.monthly_costs = cache.monthly_costs; + self.service_costs = cache.service_costs; + self.service_cost_insights = cache.service_cost_insights; + // Only successful fetches are cached, so a cache hit is Ok data. + self.cost_status = ServiceStatus::Ok; + self.cost_loaded = true; + self.rebuild_cost_savings(); + } + + /// Fetch cost data live from Cost Explorer and cache it on success. + pub async fn refresh_cost_data(&mut self) { + let profile = self.current_profile.clone(); + let region = self.current_region_label().to_string(); + let (budget, budget_status) = aws::cost::fetch_budget(self).await; let (monthly_costs, monthly_status) = aws::cost::fetch_last_6_month_costs(self).await; let (service_cost_insights, insights_status) = diff --git a/src/app/refresh.rs b/src/app/refresh.rs index 805531a..2338306 100644 --- a/src/app/refresh.rs +++ b/src/app/refresh.rs @@ -7,7 +7,10 @@ use std::time::{Duration, Instant}; use tokio::sync::mpsc::UnboundedSender; use crate::app::{ActiveView, App, RefreshPhase}; +use aws_sdk_sts::operation::get_caller_identity::GetCallerIdentityOutput; + use crate::aws; +use crate::aws::account::OverviewParts; use crate::aws::pricing::PriceBook; use crate::models::apigateway::ApiGatewayInfo; use crate::models::cloudwatch::{CloudWatchAlarm, CloudWatchSummary}; @@ -113,10 +116,27 @@ impl App { /// result over `tx`. Consumes the worker (it is a throwaway clone of the /// account context spawned onto its own task). Errors from a closed channel /// are ignored: they only happen when a newer refresh has superseded this one. - pub(crate) async fn stream_refresh(mut self, tx: UnboundedSender) { - let _ = tx.send(RefreshUpdate::Phase(RefreshPhase::Overview)); - let overview = aws::account::fetch_account_overview(&self).await; - let _ = tx.send(RefreshUpdate::AccountOverview(Box::new(overview))); + /// + /// The account overview, when requested, runs on its own task alongside the + /// view's fetches rather than ahead of them, so the view's data is not held + /// back by a dozen unrelated service calls. Findings is the exception: it + /// already fetches nearly every service the overview summarizes, so it + /// assembles the overview from its own inventories instead of fetching them + /// a second time. + pub(crate) async fn stream_refresh( + mut self, + tx: UnboundedSender, + fetch_overview: bool, + ) { + let derives_overview = self.active_view == ActiveView::Findings; + let overview = (fetch_overview && !derives_overview).then(|| { + let worker = self.refresh_worker(); + let tx = tx.clone(); + tokio::spawn(async move { + let overview = aws::account::fetch_account_overview(&worker).await; + let _ = tx.send(RefreshUpdate::AccountOverview(Box::new(overview))); + }) + }); match self.active_view { ActiveView::Findings => { @@ -149,6 +169,7 @@ impl App { (functions, lambda_status), (vpcs, vpc_status), (repositories, ecr_status), + overview_extras, ) = tokio::join!( aws::cloudwatch::fetch_cloudwatch(&self), aws::ec2::fetch_instances(&self), @@ -161,8 +182,30 @@ impl App { aws::lambda::fetch_lambda_functions(&self), aws::vpc::fetch_vpcs(&self), aws::ecr::fetch_ecr_repositories(&self), + self.fetch_overview_extras(fetch_overview), ); + if let Some((ident, ecs_clusters)) = overview_extras { + let overview = aws::account::assemble_overview( + &self, + ident, + OverviewParts { + ecs_clusters: ecs_clusters.clone(), + ec2: aws::account::ec2_counts(&ec2), + rds: rds_summary.clone(), + lambda: aws::account::lambda_summary(&functions, lambda_status.clone()), + apigw: aws::account::apigateway_summary(&apis, apigw_status.clone()), + sqs: aws::account::sqs_summary(&queues, sqs_status.clone()), + vpc: aws::account::vpc_summary(&vpcs, vpc_status.clone()), + alarms: cw_summary.clone(), + secrets: secrets_summary.clone(), + target_groups: target_groups.clone(), + }, + ); + let _ = tx.send(RefreshUpdate::AccountOverview(Box::new(overview))); + let _ = tx.send(RefreshUpdate::Ecs(ecs_clusters)); + } + let ec2_for_pricing = ec2.clone(); let lbs_for_pricing = load_balancers.clone(); @@ -300,9 +343,32 @@ impl App { } } + // `Done` rebuilds findings, which gate on the overview, so it must not + // be sent until the overview has landed. + if let Some(overview) = overview { + let _ = tx.send(RefreshUpdate::Phase(RefreshPhase::Overview)); + let _ = overview.await; + } + let _ = tx.send(RefreshUpdate::Done); } + /// What the account overview needs beyond the Findings inventories: the + /// caller identity and ECS. `None` when the overview is not being refreshed. + async fn fetch_overview_extras( + &self, + wanted: bool, + ) -> Option<(Option, Vec)> { + if !wanted { + return None; + } + + Some(tokio::join!( + aws::account::fetch_identity(self), + aws::ecs::fetch_ecs_clusters(self) + )) + } + /// Target groups must be fetched before load balancers so target-group /// health can be folded in, so this pair is fetched together and returned as /// a unit. Borrows `&self` only, so it can join the concurrent fetch set. @@ -321,7 +387,7 @@ impl App { } async fn send_cost(&mut self, tx: &UnboundedSender) { - self.refresh_cost_data(true).await; + self.refresh_cost_data().await; let _ = tx.send(RefreshUpdate::Cost { budget: self.budget.clone(), monthly_costs: self.monthly_costs.clone(), @@ -384,14 +450,15 @@ impl App { /// instead of refetching. Freshness is cleared on a profile or region /// change, so this can never serve a prior context's data. pub(crate) fn active_view_is_fresh(&self) -> bool { - let now = Instant::now(); Self::required_inventories(self.active_view) .iter() - .all(|kind| { - self.inventory_fetched_at - .get(kind) - .is_some_and(|fetched| now.duration_since(*fetched) < INVENTORY_TTL) - }) + .all(|kind| self.inventory_is_fresh(*kind)) + } + + pub(crate) fn inventory_is_fresh(&self, kind: InventoryKind) -> bool { + self.inventory_fetched_at + .get(&kind) + .is_some_and(|fetched| fetched.elapsed() < INVENTORY_TTL) } /// Apply one streamed update to the live app state. Returns `true` when the @@ -537,7 +604,9 @@ mod tests { .region(aws_config::Region::new("us-east-1")) .behavior_version(aws_config::BehaviorVersion::latest()) .build(); - App::new(AwsClients::new(&config)) + let mut app = App::new(AwsClients::new(&config)); + app.regions = vec![aws_config::Region::new("us-east-1")]; + app } #[test] @@ -600,6 +669,65 @@ mod tests { assert!(!app.active_view_is_fresh()); } + /// Navigation used to refetch the whole account overview (ten services) + /// ahead of the view's own data on every view switch. + #[tokio::test] + async fn navigation_reuses_a_fresh_account_overview() { + let mut app = test_app(); + app.data_context = Some((app.current_profile.clone(), app.current_region_label())); + mark_fresh(&mut app, InventoryKind::AccountOverview); + app.active_view = ActiveView::Ec2; + + app.on_view_enter().await; + + assert!(app.is_refreshing, "EC2 itself was never fetched"); + assert!(matches!(app.refresh_phase, RefreshPhase::Services(_))); + } + + #[tokio::test] + async fn navigation_refetches_a_stale_account_overview() { + let mut app = test_app(); + app.active_view = ActiveView::Ec2; + + app.on_view_enter().await; + + assert!(matches!(app.refresh_phase, RefreshPhase::Overview)); + } + + #[tokio::test] + async fn a_manual_refresh_always_refetches_the_account_overview() { + let mut app = test_app(); + app.data_context = Some((app.current_profile.clone(), app.current_region_label())); + mark_fresh(&mut app, InventoryKind::AccountOverview); + + app.trigger_refresh(); + + assert!(matches!(app.refresh_phase, RefreshPhase::Overview)); + } + + /// The header keeps showing the account while a same-context refresh runs, + /// instead of blanking until the new overview lands. + #[tokio::test] + async fn a_same_context_refresh_keeps_the_account_overview() { + let mut app = test_app(); + app.data_context = Some((app.current_profile.clone(), app.current_region_label())); + app.account_overview = Some(crate::app::findings::tests::overview()); + + app.trigger_refresh(); + + assert!(app.account_overview.is_some()); + } + + #[test] + fn clearing_service_data_drops_the_account_overview() { + let mut app = test_app(); + app.account_overview = Some(crate::app::findings::tests::overview()); + + app.clear_service_data(); + + assert!(app.account_overview.is_none()); + } + #[test] fn clearing_service_data_resets_freshness() { let mut app = test_app(); diff --git a/src/aws/account.rs b/src/aws/account.rs index a3b1696..9e82537 100644 --- a/src/aws/account.rs +++ b/src/aws/account.rs @@ -1,8 +1,21 @@ +use aws_sdk_sts::operation::get_caller_identity::GetCallerIdentityOutput; +use tokio::join; + use crate::app::App; use crate::aws::apigateway; +use crate::aws::ec2::Ec2Counts; use crate::aws::{cloudwatch, ec2, ecs, lambda, rds, secrets, sqs, target_group, vpc}; -use crate::models::AccountOverview; -use tokio::join; +use crate::models::apigateway::{ApiGatewayInfo, ApiGatewaySummary}; +use crate::models::cloudwatch::CloudWatchSummary; +use crate::models::ec2::Ec2InstanceInfo; +use crate::models::lambda::{LambdaFunctionInfo, LambdaSummary}; +use crate::models::rds::RdsSummary; +use crate::models::secrets::SecretsSummary; +use crate::models::service_status::ServiceStatus; +use crate::models::sqs::{SqsQueueInfo, SqsSummary}; +use crate::models::target_group::TargetGroupInfo; +use crate::models::vpc::{VpcInfo, VpcSummary}; +use crate::models::{AccountOverview, EcsClusterInfo}; pub struct IdentityInfo { pub kind: String, @@ -38,25 +51,45 @@ fn parse_identity_from_arn(arn: &str) -> IdentityInfo { } } -pub async fn fetch_account_overview(app: &App) -> AccountOverview { +/// Every service summary the account overview is assembled from. +/// +/// The overview can either fetch these itself or be handed them by a view that +/// already fetched the full inventories, which is how Findings avoids fetching +/// the same services twice. +pub struct OverviewParts { + pub ecs_clusters: Vec, + pub ec2: Ec2Counts, + pub rds: RdsSummary, + pub lambda: LambdaSummary, + pub apigw: ApiGatewaySummary, + pub sqs: SqsSummary, + pub vpc: VpcSummary, + pub alarms: CloudWatchSummary, + pub secrets: SecretsSummary, + pub target_groups: Vec, +} + +pub async fn fetch_identity(app: &App) -> Option { // TODO(#16): surface a denied/throttled sts:GetCallerIdentity in the UI once // the error surface exists; for now degrade to an unknown identity. - let ident = app.aws.sts.get_caller_identity().send().await.ok(); - let arn = ident.as_ref().and_then(|i| i.arn()).unwrap_or(""); - let identity = parse_identity_from_arn(arn); + app.aws.sts.get_caller_identity().send().await.ok() +} +pub async fn fetch_account_overview(app: &App) -> AccountOverview { let ( + ident, ecs_clusters, - ec2_counts, - rds_result, - lambda_result, - apigw_result, - sqs_result, - vpc_result, - alarms, - secrets, - target_groups, + ec2, + (rds, _), + lambda, + apigw, + sqs, + vpc, + (alarms, _), + (secrets, _), + (target_groups, _), ) = join!( + fetch_identity(app), ecs::fetch_ecs_clusters(app), ec2::fetch_ec2_counts(app), rds::fetch_rds(app), @@ -69,14 +102,43 @@ pub async fn fetch_account_overview(app: &App) -> AccountOverview { target_group::fetch_target_groups(app) ); - let ecs_clusters_count = ecs_clusters.len() as u32; - let ecs_services_count: u32 = ecs_clusters.iter().map(|c| c.active_services as u32).sum(); + assemble_overview( + app, + ident, + OverviewParts { + ecs_clusters, + ec2, + rds, + lambda, + apigw, + sqs, + vpc, + alarms, + secrets, + // The overview only needs the list here; the fetch status is + // surfaced in the dedicated Target Groups view. + target_groups, + }, + ) +} + +pub fn assemble_overview( + app: &App, + ident: Option, + parts: OverviewParts, +) -> AccountOverview { + let arn = ident.as_ref().and_then(|i| i.arn()).unwrap_or(""); + let identity = parse_identity_from_arn(arn); - // The account overview only needs the target-group list here; the fetch - // status is surfaced in the dedicated Target Groups view. - let (target_groups, _) = target_groups; + let ecs_clusters_count = parts.ecs_clusters.len() as u32; + let ecs_services_count: u32 = parts + .ecs_clusters + .iter() + .map(|c| c.active_services as u32) + .sum(); - let unhealthy = target_groups + let unhealthy = parts + .target_groups .iter() .filter(|tg| tg.unhealthy_targets > 0) .count(); @@ -97,31 +159,191 @@ pub async fn fetch_account_overview(app: &App) -> AccountOverview { .and_then(|s| s.split('/').next()) .map(|s| s.to_string()), - ec2_running: ec2_counts.running, - ec2_stopped: ec2_counts.stopped, + ec2_running: parts.ec2.running, + ec2_stopped: parts.ec2.stopped, ecs_clusters: ecs_clusters_count, ecs_services: ecs_services_count, - rds_status: rds_result.0, - lambda_functions: lambda_result.function_count, - lambda_status: lambda_result.status, + rds_status: parts.rds, + lambda_functions: parts.lambda.function_count, + lambda_status: parts.lambda.status, - apigw_rest_apis: apigw_result.rest_count, - apigw_http_apis: apigw_result.http_count, - apigw_status: apigw_result.status, + apigw_rest_apis: parts.apigw.rest_count, + apigw_http_apis: parts.apigw.http_count, + apigw_status: parts.apigw.status, - sqs_queues: sqs_result.queue_count, - sqs_dlqs: sqs_result.dlq_count, - sqs_status: sqs_result.status, + sqs_queues: parts.sqs.queue_count, + sqs_dlqs: parts.sqs.dlq_count, + sqs_status: parts.sqs.status, - vpc_count: vpc_result.vpc_count, - subnet_count: vpc_result.subnet_count, - vpc_status: vpc_result.status, - alarms: alarms.0, - secrets: secrets.0, + vpc_count: parts.vpc.vpc_count, + subnet_count: parts.vpc.subnet_count, + vpc_status: parts.vpc.status, + alarms: parts.alarms, + secrets: parts.secrets, - target_groups_total: target_groups.len(), + target_groups_total: parts.target_groups.len(), target_groups_unhealthy: unhealthy, } } + +pub fn lambda_summary(functions: &[LambdaFunctionInfo], status: ServiceStatus) -> LambdaSummary { + LambdaSummary { + function_count: functions.len() as u32, + status, + } +} + +/// HTTP and WebSocket APIs both come from API Gateway v2, and the overview +/// counts every v2 API as HTTP, as its own summary fetch does. +pub fn apigateway_summary(apis: &[ApiGatewayInfo], status: ServiceStatus) -> ApiGatewaySummary { + let rest_count = apis.iter().filter(|api| api.api_type == "REST").count() as u32; + + ApiGatewaySummary { + rest_count, + http_count: apis.len() as u32 - rest_count, + status, + } +} + +pub fn sqs_summary(queues: &[SqsQueueInfo], status: ServiceStatus) -> SqsSummary { + SqsSummary { + queue_count: queues.len() as u32, + dlq_count: queues.iter().filter(|queue| queue.has_dlq).count() as u32, + status, + } +} + +pub fn vpc_summary(vpcs: &[VpcInfo], status: ServiceStatus) -> VpcSummary { + VpcSummary { + vpc_count: vpcs.len() as u32, + subnet_count: vpcs.iter().map(|vpc| vpc.subnet_count).sum(), + status, + } +} + +pub fn ec2_counts(instances: &[Ec2InstanceInfo]) -> Ec2Counts { + let mut counts = Ec2Counts { + running: 0, + stopped: 0, + }; + + for instance in instances { + match instance.state.as_str() { + "running" => counts.running += 1, + "stopped" => counts.stopped += 1, + _ => {} + } + } + + counts +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::tags::Tags; + + fn api(api_type: &str) -> ApiGatewayInfo { + ApiGatewayInfo { + id: "a1".into(), + name: "orders".into(), + api_type: api_type.into(), + created_at: "-".into(), + tags: Tags::empty(), + } + } + + fn queue(has_dlq: bool) -> SqsQueueInfo { + SqsQueueInfo { + name: "orders".into(), + queue_url: "https://sqs.us-east-1.amazonaws.com/123456789012/orders".into(), + is_fifo: false, + messages_available: 0, + messages_in_flight: 0, + has_dlq, + dead_letter_target_arn: None, + tags: Tags::empty(), + } + } + + fn vpc(subnet_count: u32) -> VpcInfo { + VpcInfo { + vpc_id: "vpc-1".into(), + cidr: "10.0.0.0/16".into(), + state: "Available".into(), + is_default: false, + subnet_count, + tags: Tags::empty(), + } + } + + fn instance(state: &str) -> Ec2InstanceInfo { + Ec2InstanceInfo { + id: "i-1".into(), + tags: Tags::empty(), + avg_cpu_utilization: None, + instance_type: "t3.micro".into(), + state: state.into(), + region: "us-east-1".into(), + az: "us-east-1a".into(), + private_ip: None, + public_ip: None, + key_name: None, + } + } + + /// The summary fetch counts every API Gateway v2 API as HTTP, including + /// WebSocket APIs, so the derived summary must match it. + #[test] + fn every_non_rest_api_counts_as_http() { + let apis = vec![api("REST"), api("REST"), api("Http"), api("Websocket")]; + + let summary = apigateway_summary(&apis, ServiceStatus::Ok); + + assert_eq!(summary.rest_count, 2); + assert_eq!(summary.http_count, 2); + } + + #[test] + fn dlq_count_is_queues_with_a_redrive_policy() { + let queues = vec![queue(true), queue(false), queue(true)]; + + let summary = sqs_summary(&queues, ServiceStatus::Ok); + + assert_eq!(summary.queue_count, 3); + assert_eq!(summary.dlq_count, 2); + } + + #[test] + fn subnet_count_sums_across_vpcs() { + let summary = vpc_summary(&[vpc(3), vpc(2)], ServiceStatus::Ok); + + assert_eq!(summary.vpc_count, 2); + assert_eq!(summary.subnet_count, 5); + } + + #[test] + fn only_running_and_stopped_instances_are_counted() { + let instances = vec![ + instance("running"), + instance("running"), + instance("stopped"), + instance("terminated"), + ]; + + let counts = ec2_counts(&instances); + + assert_eq!(counts.running, 2); + assert_eq!(counts.stopped, 1); + } + + #[test] + fn a_failed_fetch_carries_its_status_into_the_summary() { + let summary = lambda_summary(&[], ServiceStatus::AccessDenied); + + assert_eq!(summary.function_count, 0); + assert!(matches!(summary.status, ServiceStatus::AccessDenied)); + } +} diff --git a/src/aws/ec2.rs b/src/aws/ec2.rs index baa5010..58be0e6 100644 --- a/src/aws/ec2.rs +++ b/src/aws/ec2.rs @@ -192,16 +192,5 @@ pub async fn fetch_instances(app: &App) -> (Vec, ServiceStatus) pub async fn fetch_ec2_counts(app: &App) -> Ec2Counts { let (instances, _status) = fetch_instances_with_metrics(app, false).await; - let mut running = 0; - let mut stopped = 0; - - for instance in instances { - match instance.state.as_str() { - "running" => running += 1, - "stopped" => stopped += 1, - _ => {} - } - } - - Ec2Counts { running, stopped } + crate::aws::account::ec2_counts(&instances) } diff --git a/src/aws/lambda.rs b/src/aws/lambda.rs index d48ccb8..0b1a0aa 100644 --- a/src/aws/lambda.rs +++ b/src/aws/lambda.rs @@ -15,6 +15,7 @@ const UNAVAILABLE_EVERYWHERE: &str = "Lambda unavailable in all regions"; async fn fetch_lambda_for_region( region: Region, + include_tags: bool, profile: Option, ) -> Result, ServiceStatus> { let aws = clients_for_region(®ion, profile.as_deref()).await; @@ -46,6 +47,10 @@ async fn fetch_lambda_for_region( }); } + if !include_tags { + return Ok(functions); + } + // ListFunctions does not return tags, so they need one ListTags per function. let looked_up = tags::lookup_each(arns, |arn| { let aws = &aws; @@ -64,15 +69,21 @@ async fn fetch_lambda_for_region( } pub async fn fetch_lambda_functions(app: &App) -> (Vec, ServiceStatus) { + fetch_lambda(app, true).await +} + +/// `include_tags` costs one `ListTags` call per function, so callers that only +/// need a count skip it. +async fn fetch_lambda(app: &App, include_tags: bool) -> (Vec, ServiceStatus) { let profile = app.current_profile.clone(); let (mut functions, status) = if app.is_global_region_selected() { fetch_all_regions(&app.regions, UNAVAILABLE_EVERYWHERE, move |region| { - fetch_lambda_for_region(region, profile.clone()) + fetch_lambda_for_region(region, include_tags, profile.clone()) }) .await } else { - match fetch_lambda_for_region(app.current_region().clone(), profile).await { + match fetch_lambda_for_region(app.current_region().clone(), include_tags, profile).await { Ok(functions) => (functions, ServiceStatus::Ok), Err(status) => (vec![], status), } @@ -84,10 +95,7 @@ pub async fn fetch_lambda_functions(app: &App) -> (Vec, Serv } pub async fn fetch_lambda_summary(app: &App) -> LambdaSummary { - let (functions, status) = fetch_lambda_functions(app).await; + let (functions, status) = fetch_lambda(app, false).await; - LambdaSummary { - function_count: functions.len() as u32, - status, - } + crate::aws::account::lambda_summary(&functions, status) } diff --git a/src/main.rs b/src/main.rs index d354002..ec045e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -264,7 +264,7 @@ async fn main() -> anyhow::Result<()> { app.theme = crate::ui::theme::Theme::from_name(theme_name); } - app.load_cost_data().await; + app.load_cached_cost_data(); app.trigger_refresh(); // Raise warnings last so their display window starts at the first draw