Skip to content
Open
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
13 changes: 7 additions & 6 deletions api/src/routes/v2/envs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
},
};
Expand All @@ -27,7 +27,7 @@ use uuid::Uuid;
responses(
(
status = OK,
body = Paginated<Env>,
body = Paginated<EnvWithStats>,
),
),
)]
Expand All @@ -38,8 +38,9 @@ async fn get_all(
pagination: web::Query<PaginationParams>,
) -> 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(
Expand All @@ -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<Uuid>) -> 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(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
drop trigger notify_env_deployment_count_changes on deployments;
drop function notify_env_deployment_count;
50 changes: 50 additions & 0 deletions db/migrations/2026-06-14-191000_env-deployment-count-events/up.sql
Original file line number Diff line number Diff line change
@@ -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();
46 changes: 45 additions & 1 deletion db/src/schema/deployment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand All @@ -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;
Expand Down Expand Up @@ -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<HashMap<Uuid, i64>> {
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<Uuid, Uuid> = k8s_clusters::table
.select((k8s_clusters::id, k8s_clusters::env_id))
.get_results::<(Uuid, Option<Uuid>)>(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<Uuid, i64> = 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<i64> {
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)]
Expand Down
48 changes: 47 additions & 1 deletion db/src/schema/env.rs
Original file line number Diff line number Diff line change
@@ -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::*;
Expand Down Expand Up @@ -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<Vec<Self>> {
Ok(envs::table
Expand Down Expand Up @@ -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<Paginated<EnvWithStats>> {
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<EnvWithStats> {
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))
Expand Down