diff --git a/.changeset/ecs-real-capacity-and-status.md b/.changeset/ecs-real-capacity-and-status.md new file mode 100644 index 0000000..48408ce --- /dev/null +++ b/.changeset/ecs-real-capacity-and-status.md @@ -0,0 +1,5 @@ +--- +"seamless-glance": patch +--- + +Report real ECS cluster capacity and status instead of placeholders. The CPU and Memory columns were both set to the registered container-instance count, so three columns showed the same unrelated number, and the Health column was hardcoded to OK regardless of the cluster. CPU and memory are now the share of registered capacity in use, read from the container instances backing the cluster, and Status is the lifecycle state ECS reports. Fargate clusters register no instances and have no cluster-level capacity pool, so they show a dash rather than a zero that would read as an idle cluster. Capacity is only looked up for clusters that actually have container instances, so a Fargate-only account makes no extra API calls. diff --git a/src/aws/ecs.rs b/src/aws/ecs.rs index e9c8985..17e7e2b 100644 --- a/src/aws/ecs.rs +++ b/src/aws/ecs.rs @@ -1,5 +1,71 @@ -use crate::{app::App, aws::tags, models::EcsClusterInfo}; +use crate::{ + app::App, + aws::tags, + models::{ecs::ClusterCapacity, EcsClusterInfo}, +}; use aws_sdk_ecs::types::ClusterField; +use aws_sdk_ecs::Client as EcsClient; + +/// Resource names ECS reports on a container instance. +const CPU_RESOURCE: &str = "CPU"; +const MEMORY_RESOURCE: &str = "MEMORY"; + +/// Sum one named resource across a set of container-instance resource lists. +fn total(resources: &[aws_sdk_ecs::types::Resource], name: &str) -> i32 { + resources + .iter() + .filter(|resource| resource.name() == Some(name)) + .map(|resource| resource.integer_value()) + .sum() +} + +/// Capacity registered by the instances backing a cluster. +/// +/// Only called when the cluster reports container instances, so a Fargate-only +/// cluster costs no extra requests. Returns `None` if the lookup fails, leaving +/// the columns blank rather than reporting a capacity that was never read. +async fn fetch_cluster_capacity(ecs: &EcsClient, cluster_arn: &str) -> Option { + let mut capacity = ClusterCapacity { + registered_cpu_units: 0, + available_cpu_units: 0, + registered_memory_mib: 0, + available_memory_mib: 0, + }; + + let mut pages = ecs + .list_container_instances() + .cluster(cluster_arn) + .into_paginator() + .items() + .send(); + + let mut instance_arns = Vec::new(); + while let Some(item) = pages.next().await { + instance_arns.push(item.ok()?); + } + + // DescribeContainerInstances accepts at most 100 identifiers per call. + for chunk in instance_arns.chunks(100) { + let mut request = ecs.describe_container_instances().cluster(cluster_arn); + for arn in chunk { + request = request.container_instances(arn); + } + + let response = request.send().await.ok()?; + + for instance in response.container_instances() { + let registered = instance.registered_resources(); + let remaining = instance.remaining_resources(); + + capacity.registered_cpu_units += total(registered, CPU_RESOURCE); + capacity.available_cpu_units += total(remaining, CPU_RESOURCE); + capacity.registered_memory_mib += total(registered, MEMORY_RESOURCE); + capacity.available_memory_mib += total(remaining, MEMORY_RESOURCE); + } + } + + Some(capacity) +} pub async fn fetch_ecs_clusters(app: &App) -> Vec { // TODO(#16): surface throttle/denied errors in the UI instead of degrading @@ -33,6 +99,14 @@ pub async fn fetch_ecs_clusters(app: &App) -> Vec { }; for c in resp.clusters() { + // Fargate clusters register no instances and have no capacity pool, + // so this stays None and costs no extra requests. + let capacity = if c.registered_container_instances_count() > 0 { + fetch_cluster_capacity(&app.aws.ecs, c.cluster_arn().unwrap_or_default()).await + } else { + None + }; + clusters.push(EcsClusterInfo { tags: tags::from_pairs(c.tags().iter().map(|t| (t.key(), t.value()))), arn: c.cluster_arn().unwrap_or("").into(), @@ -41,8 +115,8 @@ pub async fn fetch_ecs_clusters(app: &App) -> Vec { pending_tasks: c.pending_tasks_count(), active_services: c.active_services_count(), registered_container_instances: c.registered_container_instances_count(), - cpu: c.registered_container_instances_count(), // placeholder - memory: c.registered_container_instances_count(), // placeholder + status: c.status().unwrap_or_default().to_string(), + capacity, }); } } diff --git a/src/models/ecs.rs b/src/models/ecs.rs index 33833a7..f2371ce 100644 --- a/src/models/ecs.rs +++ b/src/models/ecs.rs @@ -7,6 +7,43 @@ use crate::{ models::tags::Tags, }; +/// Compute capacity registered with a cluster by the container instances +/// backing it. +/// +/// Only EC2-backed clusters have one. Fargate provisions capacity per task with +/// no cluster-level pool, so there is no total to report and those clusters +/// carry `None` rather than a zero that would read as an empty cluster. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClusterCapacity { + pub registered_cpu_units: i32, + pub available_cpu_units: i32, + pub registered_memory_mib: i32, + pub available_memory_mib: i32, +} + +impl ClusterCapacity { + /// Share of registered capacity currently claimed by tasks, as a percent. + /// + /// `None` when nothing is registered, which would otherwise divide by zero. + fn used_percent(registered: i32, available: i32) -> Option { + if registered <= 0 { + return None; + } + + let used = registered.saturating_sub(available).max(0); + + Some((used as i64 * 100 / registered as i64) as u32) + } + + pub fn cpu_used_percent(&self) -> Option { + Self::used_percent(self.registered_cpu_units, self.available_cpu_units) + } + + pub fn memory_used_percent(&self) -> Option { + Self::used_percent(self.registered_memory_mib, self.available_memory_mib) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EcsClusterInfo { pub arn: String, @@ -15,11 +52,45 @@ pub struct EcsClusterInfo { pub pending_tasks: i32, pub active_services: i32, pub registered_container_instances: i32, - pub cpu: i32, - pub memory: i32, + /// Cluster lifecycle status as ECS reports it: ACTIVE, PROVISIONING, + /// DEPROVISIONING, FAILED, or INACTIVE. + #[serde(default)] + pub status: String, + /// `None` for Fargate-only clusters, which have no capacity pool. + #[serde(default)] + pub capacity: Option, pub tags: Tags, } +impl EcsClusterInfo { + /// How a capacity share renders in a column, or `-` when the cluster has no + /// capacity pool to measure against. + fn capacity_label(percent: Option) -> String { + percent.map_or_else(|| "-".to_string(), |value| format!("{value}%")) + } + + pub fn cpu_label(&self) -> String { + Self::capacity_label(self.capacity.and_then(|c| c.cpu_used_percent())) + } + + pub fn memory_label(&self) -> String { + Self::capacity_label(self.capacity.and_then(|c| c.memory_used_percent())) + } + + pub fn status_label(&self) -> String { + if self.status.is_empty() { + "-".to_string() + } else { + self.status.clone() + } + } + + /// Whether the cluster is in its normal serving state. + pub fn is_active(&self) -> bool { + self.status.eq_ignore_ascii_case("ACTIVE") + } +} + #[async_trait] impl DescribableResource for EcsClusterInfo { fn resource_name(&self) -> String { @@ -53,3 +124,126 @@ impl DescribableResource for EcsClusterInfo { )) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn cluster(capacity: Option) -> EcsClusterInfo { + EcsClusterInfo { + arn: "arn:aws:ecs:us-east-1:1:cluster/core".into(), + name: "core".into(), + running_tasks: 4, + pending_tasks: 0, + active_services: 4, + registered_container_instances: 0, + status: "ACTIVE".into(), + capacity, + tags: Tags::empty(), + } + } + + fn capacity(reg_cpu: i32, avail_cpu: i32, reg_mem: i32, avail_mem: i32) -> ClusterCapacity { + ClusterCapacity { + registered_cpu_units: reg_cpu, + available_cpu_units: avail_cpu, + registered_memory_mib: reg_mem, + available_memory_mib: avail_mem, + } + } + + #[test] + fn utilization_is_the_share_of_registered_capacity_in_use() { + let half_cpu = capacity(4096, 2048, 8192, 6144); + + assert_eq!(half_cpu.cpu_used_percent(), Some(50)); + assert_eq!(half_cpu.memory_used_percent(), Some(25)); + } + + #[test] + fn a_fully_free_cluster_reads_as_zero_not_absent() { + let idle = capacity(4096, 4096, 8192, 8192); + + assert_eq!(idle.cpu_used_percent(), Some(0)); + assert_eq!(cluster(Some(idle)).cpu_label(), "0%"); + } + + #[test] + fn a_fully_claimed_cluster_reads_as_one_hundred() { + let full = capacity(4096, 0, 8192, 0); + + assert_eq!(full.cpu_used_percent(), Some(100)); + assert_eq!(full.memory_used_percent(), Some(100)); + } + + /// Fargate registers no instances, so there is no pool to measure against. + /// Reporting 0% would read as an idle cluster rather than an inapplicable + /// measurement, which is what the placeholder used to do. + #[test] + fn a_fargate_cluster_reports_no_utilization() { + let fargate = cluster(None); + + assert_eq!(fargate.cpu_label(), "-"); + assert_eq!(fargate.memory_label(), "-"); + } + + #[test] + fn nothing_registered_cannot_divide_by_zero() { + let empty = capacity(0, 0, 0, 0); + + assert_eq!(empty.cpu_used_percent(), None); + assert_eq!(empty.memory_used_percent(), None); + assert_eq!(cluster(Some(empty)).cpu_label(), "-"); + } + + /// ECS can report more available than registered while an instance drains. + /// That is not negative usage. + #[test] + fn more_available_than_registered_is_clamped_to_zero_used() { + let draining = capacity(4096, 5000, 8192, 9000); + + assert_eq!(draining.cpu_used_percent(), Some(0)); + assert_eq!(draining.memory_used_percent(), Some(0)); + } + + /// Clusters cached before this field existed still load. + #[test] + fn a_cached_cluster_without_capacity_still_deserializes() { + let older = r#"{ + "arn": "arn:aws:ecs:us-east-1:1:cluster/core", + "name": "core", + "running_tasks": 4, + "pending_tasks": 0, + "active_services": 4, + "registered_container_instances": 0, + "tags": "Unavailable" + }"#; + + let restored: EcsClusterInfo = serde_json::from_str(older).expect("older shape loads"); + + assert_eq!(restored.capacity, None); + assert_eq!(restored.cpu_label(), "-"); + // Status was not stored either, so it reads as unknown rather than OK. + assert_eq!(restored.status_label(), "-"); + } + + #[test] + fn a_cluster_reports_the_status_ecs_gave_it() { + let mut provisioning = cluster(None); + provisioning.status = "PROVISIONING".into(); + + assert_eq!(provisioning.status_label(), "PROVISIONING"); + assert!(!provisioning.is_active()); + assert!(cluster(None).is_active()); + } + + /// Never invent a status. An empty one means it was not reported. + #[test] + fn an_unreported_status_is_not_claimed_to_be_healthy() { + let mut unknown = cluster(None); + unknown.status = String::new(); + + assert_eq!(unknown.status_label(), "-"); + assert!(!unknown.is_active()); + } +} diff --git a/src/ui/views/ecs.rs b/src/ui/views/ecs.rs index 5310287..cc014e5 100644 --- a/src/ui/views/ecs.rs +++ b/src/ui/views/ecs.rs @@ -32,9 +32,9 @@ pub fn render_ecs_clusters(frame: &mut Frame, area: Rect, app: &mut App) { "Services", "Tasks (R/P)", "EC2s", - "CPU", - "Memory", - "Health", + "CPU Used", + "Mem Used", + "Status", ], widths: &[ Constraint::Percentage(30), @@ -57,12 +57,16 @@ pub fn render_ecs_clusters(frame: &mut Frame, area: Rect, app: &mut App) { c.active_services.to_string(), format!("{} / {}", c.running_tasks, c.pending_tasks), c.registered_container_instances.to_string(), - c.cpu.to_string(), - c.memory.to_string(), - // TODO(#43): cluster health is a placeholder, not yet computed. - "OK".to_string(), + c.cpu_label(), + c.memory_label(), + c.status_label(), ], - style: Style::default().fg(theme.text), + style: if c.is_active() { + Style::default().fg(theme.text) + } else { + // Anything other than ACTIVE is worth noticing. + Style::default().fg(theme.primary) + }, } }, );