From 892c841e99911d3aef92902290b36f0dabdc8b64 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 19:30:31 +0000 Subject: [PATCH] Return live per-env deployment counts on the env endpoints The env list/detail endpoints now return each environment's deployment count (EnvWithStats = Env + num_deployments), computed without loading the deployments: counts are grouped by cluster in the database and summed per environment (Deployment::count_by_env / count_in_env). To keep the count live, a new trigger emits a synthetic `envs` refresh event on the generic `db_notifications` channel whenever a deployment change affects a count -- INSERT, DELETE, or a move between clusters (status and config updates are ignored). The frontend, which already loads and subscribes to envs, refetches the affected env and shows the new count. Stacked on the generic-trigger env_id fix, whose env-tagged events and wildcard subscription matching are what let the synthetic envs event reach subscribers. Verified against PostgreSQL that the trigger emits only on count-affecting changes (and for both environments on a move), and that the OpenAPI schema exposes num_deployments on the env responses. --- api/src/routes/v2/envs.rs | 13 ++--- .../down.sql | 2 + .../up.sql | 50 +++++++++++++++++++ db/src/schema/deployment.rs | 46 ++++++++++++++++- db/src/schema/env.rs | 48 +++++++++++++++++- 5 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 db/migrations/2026-06-14-191000_env-deployment-count-events/down.sql create mode 100644 db/migrations/2026-06-14-191000_env-deployment-count-events/up.sql diff --git a/api/src/routes/v2/envs.rs b/api/src/routes/v2/envs.rs index b3c8e13..afe64f9 100644 --- a/api/src/routes/v2/envs.rs +++ b/api/src/routes/v2/envs.rs @@ -8,7 +8,7 @@ use platz_db::{ diesel_pagination::{Paginated, PaginationParams}, schema::{ deployment::Deployment, - env::{Env, EnvFilters, NewEnv, UpdateEnv}, + env::{Env, EnvFilters, EnvWithStats, NewEnv, UpdateEnv}, env_user_permission::{EnvUserRole, NewEnvUserPermission}, }, }; @@ -27,7 +27,7 @@ use uuid::Uuid; responses( ( status = OK, - body = Paginated, + body = Paginated, ), ), )] @@ -38,8 +38,9 @@ async fn get_all( pagination: web::Query, ) -> ApiResult { let scope = AccessScope::for_identity(identity.inner()).await?; - Ok(HttpResponse::Ok() - .json(Env::all_filtered(filters.into_inner(), pagination.into_inner(), &scope).await?)) + Ok(HttpResponse::Ok().json( + Env::all_filtered_with_stats(filters.into_inner(), pagination.into_inner(), &scope).await?, + )) } #[utoipa::path( @@ -53,14 +54,14 @@ async fn get_all( responses( ( status = OK, - body = Env, + body = EnvWithStats, ), ), )] #[get("/envs/{id}")] async fn get_one(identity: ApiIdentity, id: web::Path) -> ApiResult { let scope = AccessScope::for_identity(identity.inner()).await?; - Ok(HttpResponse::Ok().json(Env::find_scoped(id.into_inner(), &scope).await?)) + Ok(HttpResponse::Ok().json(Env::find_scoped_with_stats(id.into_inner(), &scope).await?)) } #[utoipa::path( diff --git a/db/migrations/2026-06-14-191000_env-deployment-count-events/down.sql b/db/migrations/2026-06-14-191000_env-deployment-count-events/down.sql new file mode 100644 index 0000000..f2bf406 --- /dev/null +++ b/db/migrations/2026-06-14-191000_env-deployment-count-events/down.sql @@ -0,0 +1,2 @@ +drop trigger notify_env_deployment_count_changes on deployments; +drop function notify_env_deployment_count; diff --git a/db/migrations/2026-06-14-191000_env-deployment-count-events/up.sql b/db/migrations/2026-06-14-191000_env-deployment-count-events/up.sql new file mode 100644 index 0000000..8d536c4 --- /dev/null +++ b/db/migrations/2026-06-14-191000_env-deployment-count-events/up.sql @@ -0,0 +1,50 @@ +-- Keep per-environment deployment counts live. The env list/detail endpoints +-- return each env's deployment count, and the frontend keeps it current by +-- refetching an env whenever it receives an `envs` change event. A deployment +-- insert/delete (or a move between clusters) changes a count but does not touch +-- the envs table, so this trigger emits a synthetic `envs` refresh event on the +-- generic `db_notifications` channel for the affected environment(s). +-- +-- Only count-affecting changes emit, to avoid noise from frequent status/config +-- updates: INSERT (new env), DELETE (old env), and UPDATE only when the +-- deployment moved to a different cluster (and thus possibly a different env). + +CREATE FUNCTION notify_env_deployment_count() RETURNS trigger AS $trigger$ +DECLARE + new_env UUID; + old_env UUID; +BEGIN + IF TG_OP = 'INSERT' THEN + SELECT env_id INTO new_env FROM k8s_clusters WHERE id = NEW.cluster_id; + ELSIF TG_OP = 'DELETE' THEN + SELECT env_id INTO old_env FROM k8s_clusters WHERE id = OLD.cluster_id; + ELSIF TG_OP = 'UPDATE' AND NEW.cluster_id IS DISTINCT FROM OLD.cluster_id THEN + SELECT env_id INTO new_env FROM k8s_clusters WHERE id = NEW.cluster_id; + SELECT env_id INTO old_env FROM k8s_clusters WHERE id = OLD.cluster_id; + ELSE + RETURN NULL; + END IF; + + IF new_env IS NOT NULL THEN + PERFORM pg_notify( + 'db_notifications', + '{"timestamp":"' || CURRENT_TIMESTAMP + || '","operation":"UPDATE","schema":"public","table":"envs","env_id":"' + || new_env::TEXT || '","data":{"id":"' || new_env::TEXT || '"}}'); + END IF; + + IF old_env IS NOT NULL AND old_env IS DISTINCT FROM new_env THEN + PERFORM pg_notify( + 'db_notifications', + '{"timestamp":"' || CURRENT_TIMESTAMP + || '","operation":"UPDATE","schema":"public","table":"envs","env_id":"' + || old_env::TEXT || '","data":{"id":"' || old_env::TEXT || '"}}'); + END IF; + + RETURN NULL; +END; +$trigger$ LANGUAGE plpgsql; + +CREATE TRIGGER notify_env_deployment_count_changes +AFTER INSERT OR UPDATE OR DELETE ON deployments +FOR EACH ROW EXECUTE PROCEDURE notify_env_deployment_count(); diff --git a/db/src/schema/deployment.rs b/db/src/schema/deployment.rs index b1f5ab0..b10f031 100644 --- a/db/src/schema/deployment.rs +++ b/db/src/schema/deployment.rs @@ -3,7 +3,7 @@ use super::{ deployment_status::DeploymentReportedStatus, deployment_task::DeploymentTask, helm_chart::HelmChart, - k8s_cluster::K8sCluster, + k8s_cluster::{K8sCluster, k8s_clusters}, }; use crate::{AccessScope, DbError, DbResult, DbTableOrDeploymentResource, Identity, db_conn}; use chrono::prelude::*; @@ -18,6 +18,7 @@ use platz_chart_ext::{ actions::{ChartExtActionEndpoint, ChartExtActionTarget, ChartExtActionTargetResolver}, }; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::ops::DerefMut; use strum::{AsRefStr, Display, EnumIter, EnumString}; use url::Url; @@ -445,6 +446,49 @@ impl Deployment { .get_results(db_conn().await?.deref_mut()) .await?) } + + /// Number of deployments in each environment the identity may access, + /// keyed by environment id, without loading the deployments themselves. + /// Computed from two small aggregate/lookup queries (counts grouped by + /// cluster in the database, then summed per environment), so the env list + /// can show per-environment counts cheaply. Environments with no + /// deployments are absent from the map (callers default them to 0). + pub async fn count_by_env(scope: &AccessScope) -> DbResult> { + let per_cluster: Vec<(Uuid, i64)> = deployments::table + .group_by(deployments::cluster_id) + .select((deployments::cluster_id, diesel::dsl::count_star())) + .get_results(db_conn().await?.deref_mut()) + .await?; + + let cluster_env: HashMap = k8s_clusters::table + .select((k8s_clusters::id, k8s_clusters::env_id)) + .get_results::<(Uuid, Option)>(db_conn().await?.deref_mut()) + .await? + .into_iter() + .filter_map(|(cluster_id, env_id)| env_id.map(|env_id| (cluster_id, env_id))) + .collect(); + + let mut by_env: HashMap = HashMap::new(); + for (cluster_id, count) in per_cluster { + if let Some(&env_id) = cluster_env.get(&cluster_id) + && scope.allows_env(Some(env_id)) + { + *by_env.entry(env_id).or_default() += count; + } + } + + Ok(by_env) + } + + /// Number of deployments in a single environment, via the env's clusters. + pub async fn count_in_env(env_id: Uuid) -> DbResult { + let cluster_ids = K8sCluster::ids_in_envs(&[env_id]).await?; + Ok(deployments::table + .filter(deployments::cluster_id.eq_any(cluster_ids)) + .count() + .get_result(db_conn().await?.deref_mut()) + .await?) + } } #[derive(Insertable, Deserialize, ToSchema)] diff --git a/db/src/schema/env.rs b/db/src/schema/env.rs index 95ae55a..22bd812 100644 --- a/db/src/schema/env.rs +++ b/db/src/schema/env.rs @@ -1,4 +1,4 @@ -use super::k8s_cluster::K8sCluster; +use super::{deployment::Deployment, k8s_cluster::K8sCluster}; use crate::{AccessScope, DbResult, db_conn}; use chrono::prelude::*; use diesel::prelude::*; @@ -34,6 +34,16 @@ pub struct Env { pub auto_add_new_users: bool, } +/// An environment together with its live deployment count, as returned by the +/// env list/detail endpoints. The count updates live on the frontend because a +/// deployment change emits an `envs` refresh event. +#[derive(Debug, Serialize, ToSchema)] +pub struct EnvWithStats { + #[serde(flatten)] + pub env: Env, + pub num_deployments: i64, +} + impl Env { pub async fn all() -> DbResult> { Ok(envs::table @@ -73,6 +83,42 @@ impl Env { Self::find(id).await } + /// Like [`Self::all_filtered`] but augments each env with its live + /// deployment count. The count is kept current on the frontend by an `envs` + /// refresh event emitted whenever a deployment changes (see the + /// `env-deployment-count` migration). + pub async fn all_filtered_with_stats( + filters: EnvFilters, + pagination: PaginationParams, + scope: &AccessScope, + ) -> DbResult> { + let page = Self::all_filtered(filters, pagination, scope).await?; + let counts = Deployment::count_by_env(scope).await?; + Ok(Paginated { + page: page.page, + per_page: page.per_page, + num_total: page.num_total, + items: page + .items + .into_iter() + .map(|env| EnvWithStats { + num_deployments: counts.get(&env.id).copied().unwrap_or(0), + env, + }) + .collect(), + }) + } + + /// Like [`Self::find_scoped`] but augments the env with its deployment count. + pub async fn find_scoped_with_stats(id: Uuid, scope: &AccessScope) -> DbResult { + let env = Self::find_scoped(id, scope).await?; + let num_deployments = Deployment::count_in_env(env.id).await?; + Ok(EnvWithStats { + env, + num_deployments, + }) + } + pub async fn delete(&self) -> DbResult<()> { K8sCluster::detach_from_env(self.id).await?; diesel::delete(envs::table.find(self.id))