Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/faster-view-switching.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 4 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/app/findings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1241,14 +1241,14 @@ pub fn build_findings(ctx: &FindingContext) -> Vec<Finding> {
}

#[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(),
Expand Down
82 changes: 55 additions & 27 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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(),
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(), &region) {
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(), &region) 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) =
Expand Down
Loading
Loading