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))