Skip to content

Commit 391da1d

Browse files
rubenfiszelclaude
andauthored
add cloud quota usage display and version pruning (#8433)
* feat: add cloud quota usage display and version pruning Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: hard-delete pruned scripts so quota actually decreases Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: update quota error messages to reference workspace settings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9ca86f7 commit 391da1d

File tree

13 files changed

+533
-17
lines changed

13 files changed

+533
-17
lines changed

backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/.sqlx/query-9517395ac7230ab7c40c03ddd2a95fd6118b329a4421c9e8022df90ff7e775c8.json

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/.sqlx/query-d592ba371d4ad6e7f1bcffc01749f4753b6fe4e42413cce3d55f92797c856f35.json

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/.sqlx/query-ed202f69f5f0a8f21f2dbfcae98b9a94d039362537b050913ea2b7534f85a047.json

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/windmill-api-flows/src/flows.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ async fn create_flow(
452452
.await?;
453453
if nb_flows.unwrap_or(0) >= 1000 {
454454
return Err(Error::BadRequest(
455-
"You have reached the maximum number of flows (1000) on cloud. Contact support@windmill.dev to increase the limit"
455+
"You have reached the maximum number of flows (1000) on cloud. Check your usage in Workspace Settings > General > Cloud Quotas. Contact support@windmill.dev to increase the limit"
456456
.to_string(),
457457
));
458458
}

backend/windmill-api-scripts/src/scripts.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -636,7 +636,7 @@ async fn create_script_internal<'c>(
636636
.await?;
637637
if nb_scripts.unwrap_or(0) >= 5000 {
638638
return Err(Error::BadRequest(
639-
"You have reached the maximum number of scripts (5000) on cloud. Contact support@windmill.dev to increase the limit"
639+
"You have reached the maximum number of scripts (5000) on cloud. Check your usage in Workspace Settings > General > Cloud Quotas. Contact support@windmill.dev to increase the limit"
640640
.to_string(),
641641
));
642642
}

backend/windmill-api-workspaces/src/workspaces.rs

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ pub fn workspaced_service() -> Router {
158158
post(update_protection_rule).delete(delete_protection_rule),
159159
)
160160
.route("/log_chat", post(log_ai_chat))
161+
.route("/cloud_quotas", get(get_cloud_quotas))
162+
.route("/prune_versions", post(prune_versions))
161163
}
162164
pub fn global_service() -> Router {
163165
Router::new()
@@ -5398,3 +5400,199 @@ async fn log_ai_chat(
53985400
.await?;
53995401
Ok(StatusCode::NO_CONTENT)
54005402
}
5403+
5404+
#[derive(Serialize)]
5405+
struct QuotaInfo {
5406+
used: i64,
5407+
limit: i64,
5408+
prunable: i64,
5409+
}
5410+
5411+
#[derive(Serialize)]
5412+
struct CloudQuotas {
5413+
scripts: QuotaInfo,
5414+
flows: QuotaInfo,
5415+
apps: QuotaInfo,
5416+
variables: QuotaInfo,
5417+
resources: QuotaInfo,
5418+
}
5419+
5420+
async fn get_cloud_quotas(
5421+
authed: ApiAuthed,
5422+
Extension(db): Extension<DB>,
5423+
Path(w_id): Path<String>,
5424+
) -> JsonResult<CloudQuotas> {
5425+
require_admin(authed.is_admin, &authed.username)?;
5426+
5427+
if !*CLOUD_HOSTED {
5428+
return Err(Error::BadRequest(
5429+
"Cloud quotas are only available on cloud-hosted instances".to_string(),
5430+
));
5431+
}
5432+
5433+
let scripts_used =
5434+
sqlx::query_scalar!("SELECT COUNT(*) FROM script WHERE workspace_id = $1", &w_id)
5435+
.fetch_one(&db)
5436+
.await?
5437+
.unwrap_or(0);
5438+
5439+
let scripts_prunable = sqlx::query_scalar!(
5440+
"SELECT COUNT(*) FROM script s WHERE s.workspace_id = $1 AND s.hash NOT IN (
5441+
SELECT DISTINCT ON (path) hash FROM script
5442+
WHERE workspace_id = $1 AND deleted = false AND draft_only IS NOT TRUE
5443+
ORDER BY path, created_at DESC
5444+
)",
5445+
&w_id
5446+
)
5447+
.fetch_one(&db)
5448+
.await?
5449+
.unwrap_or(0);
5450+
5451+
let flows_used =
5452+
sqlx::query_scalar!("SELECT COUNT(*) FROM flow WHERE workspace_id = $1", &w_id)
5453+
.fetch_one(&db)
5454+
.await?
5455+
.unwrap_or(0);
5456+
5457+
let flows_prunable = sqlx::query_scalar!(
5458+
"SELECT COUNT(*) FROM flow_version fv
5459+
JOIN flow f ON f.workspace_id = fv.workspace_id AND f.path = fv.path
5460+
WHERE fv.workspace_id = $1 AND fv.id != f.versions[array_upper(f.versions, 1)]",
5461+
&w_id
5462+
)
5463+
.fetch_one(&db)
5464+
.await?
5465+
.unwrap_or(0);
5466+
5467+
let apps_used = sqlx::query_scalar!("SELECT COUNT(*) FROM app WHERE workspace_id = $1", &w_id)
5468+
.fetch_one(&db)
5469+
.await?
5470+
.unwrap_or(0);
5471+
5472+
let apps_prunable = sqlx::query_scalar!(
5473+
"SELECT COUNT(*) FROM app_version av
5474+
JOIN app a ON a.id = av.app_id
5475+
WHERE a.workspace_id = $1 AND av.id != a.versions[array_upper(a.versions, 1)]",
5476+
&w_id
5477+
)
5478+
.fetch_one(&db)
5479+
.await?
5480+
.unwrap_or(0);
5481+
5482+
let variables_used = sqlx::query_scalar!(
5483+
"SELECT COUNT(*) FROM variable WHERE workspace_id = $1",
5484+
&w_id
5485+
)
5486+
.fetch_one(&db)
5487+
.await?
5488+
.unwrap_or(0);
5489+
5490+
let resources_used = sqlx::query_scalar!(
5491+
"SELECT COUNT(*) FROM resource WHERE workspace_id = $1",
5492+
&w_id
5493+
)
5494+
.fetch_one(&db)
5495+
.await?
5496+
.unwrap_or(0);
5497+
5498+
Ok(Json(CloudQuotas {
5499+
scripts: QuotaInfo { used: scripts_used, limit: 5000, prunable: scripts_prunable },
5500+
flows: QuotaInfo { used: flows_used, limit: 1000, prunable: flows_prunable },
5501+
apps: QuotaInfo { used: apps_used, limit: 1000, prunable: apps_prunable },
5502+
variables: QuotaInfo { used: variables_used, limit: 10000, prunable: 0 },
5503+
resources: QuotaInfo { used: resources_used, limit: 10000, prunable: 0 },
5504+
}))
5505+
}
5506+
5507+
#[derive(Deserialize)]
5508+
struct PruneVersionsRequest {
5509+
resource_type: String,
5510+
}
5511+
5512+
#[derive(Serialize)]
5513+
struct PruneVersionsResponse {
5514+
pruned: u64,
5515+
}
5516+
5517+
async fn prune_versions(
5518+
authed: ApiAuthed,
5519+
Extension(db): Extension<DB>,
5520+
Path(w_id): Path<String>,
5521+
Json(req): Json<PruneVersionsRequest>,
5522+
) -> JsonResult<PruneVersionsResponse> {
5523+
require_admin(authed.is_admin, &authed.username)?;
5524+
5525+
if !*CLOUD_HOSTED {
5526+
return Err(Error::BadRequest(
5527+
"Version pruning is only available on cloud-hosted instances".to_string(),
5528+
));
5529+
}
5530+
5531+
let pruned = match req.resource_type.as_str() {
5532+
"scripts" => {
5533+
let result = sqlx::query(
5534+
"DELETE FROM script
5535+
WHERE workspace_id = $1 AND hash NOT IN (
5536+
SELECT DISTINCT ON (path) hash FROM script
5537+
WHERE workspace_id = $1 AND deleted = false AND draft_only IS NOT TRUE
5538+
ORDER BY path, created_at DESC
5539+
)",
5540+
)
5541+
.bind(&w_id)
5542+
.execute(&db)
5543+
.await?;
5544+
result.rows_affected()
5545+
}
5546+
"flows" => {
5547+
let deleted = sqlx::query(
5548+
"DELETE FROM flow_version fv
5549+
USING flow f
5550+
WHERE fv.workspace_id = f.workspace_id AND fv.path = f.path
5551+
AND fv.workspace_id = $1
5552+
AND fv.id != f.versions[array_upper(f.versions, 1)]",
5553+
)
5554+
.bind(&w_id)
5555+
.execute(&db)
5556+
.await?;
5557+
5558+
sqlx::query(
5559+
"UPDATE flow SET versions = ARRAY[versions[array_upper(versions, 1)]]
5560+
WHERE workspace_id = $1 AND array_length(versions, 1) > 1",
5561+
)
5562+
.bind(&w_id)
5563+
.execute(&db)
5564+
.await?;
5565+
5566+
deleted.rows_affected()
5567+
}
5568+
"apps" => {
5569+
let deleted = sqlx::query(
5570+
"DELETE FROM app_version av
5571+
USING app a
5572+
WHERE av.app_id = a.id AND a.workspace_id = $1
5573+
AND av.id != a.versions[array_upper(a.versions, 1)]",
5574+
)
5575+
.bind(&w_id)
5576+
.execute(&db)
5577+
.await?;
5578+
5579+
sqlx::query(
5580+
"UPDATE app SET versions = ARRAY[versions[array_upper(versions, 1)]]
5581+
WHERE workspace_id = $1 AND array_length(versions, 1) > 1",
5582+
)
5583+
.bind(&w_id)
5584+
.execute(&db)
5585+
.await?;
5586+
5587+
deleted.rows_affected()
5588+
}
5589+
_ => {
5590+
return Err(Error::BadRequest(format!(
5591+
"Invalid resource type '{}'. Must be 'scripts', 'flows', or 'apps'",
5592+
req.resource_type
5593+
)));
5594+
}
5595+
};
5596+
5597+
Ok(Json(PruneVersionsResponse { pruned }))
5598+
}

0 commit comments

Comments
 (0)