Compare commits
4 Commits
wmill-scri
...
hc/air-gap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
216432548d | ||
|
|
637f6f0a7d | ||
|
|
1d06c65c55 | ||
|
|
ea965a06f2 |
@@ -152,6 +152,11 @@
|
||||
"ordinal": 29,
|
||||
"name": "success_handler",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 30,
|
||||
"name": "public_app_execution_limit_per_minute",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -189,6 +194,7 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO metrics (id, value) VALUES ('telemetry', $1)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0cb84cbb9083d967cc8be1cccab5be61080c1003eef51eea41862b25c2b93de6"
|
||||
}
|
||||
15
backend/.sqlx/query-3fe6f5d77332cce5ad249b8d6e1ea34aa57650c6effc3a9a2f4f720ea934669b.json
generated
Normal file
15
backend/.sqlx/query-3fe6f5d77332cce5ad249b8d6e1ea34aa57650c6effc3a9a2f4f720ea934669b.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET public_app_execution_limit_per_minute = $1 WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3fe6f5d77332cce5ad249b8d6e1ea34aa57650c6effc3a9a2f4f720ea934669b"
|
||||
}
|
||||
3
backend/Cargo.lock
generated
3
backend/Cargo.lock
generated
@@ -15587,6 +15587,7 @@ dependencies = [
|
||||
"constant_time_eq 0.3.1",
|
||||
"cookie 0.17.0",
|
||||
"cron",
|
||||
"dashmap 6.1.0",
|
||||
"datafusion",
|
||||
"deno_core",
|
||||
"deno_error",
|
||||
@@ -15727,6 +15728,7 @@ dependencies = [
|
||||
name = "windmill-common"
|
||||
version = "1.624.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
"async-stream",
|
||||
@@ -15785,6 +15787,7 @@ dependencies = [
|
||||
"reqwest 0.13.1",
|
||||
"reqwest-middleware",
|
||||
"reqwest-retry",
|
||||
"rsa",
|
||||
"semver 1.0.27",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -344,6 +344,7 @@ const_format = { version = "0.2.35", features = ["rust_1_64", "rust_1_51"] }
|
||||
const-str = "0.5"
|
||||
constant_time_eq = "0.3.1"
|
||||
rsa = "^0"
|
||||
aes-gcm = "0.10.3"
|
||||
async_zip = { version = "0.0.17", features = ["tokio", "tokio-fs", "deflate", "chrono"] }
|
||||
once_cell = "1.17.1"
|
||||
dashmap = "6.1.0"
|
||||
|
||||
@@ -1 +1 @@
|
||||
88e49a7c9746080a8a95e30828655d5783a616d6
|
||||
c0196228860b5444c12f7b78eee6c4e151e3a686
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE workspace_settings
|
||||
DROP COLUMN IF EXISTS public_app_execution_limit_per_minute;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE workspace_settings
|
||||
ADD COLUMN IF NOT EXISTS public_app_execution_limit_per_minute INTEGER DEFAULT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op: cannot restore deleted telemetry data
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Delete all saved telemetry data from metrics table
|
||||
DELETE FROM metrics WHERE id = 'telemetry';
|
||||
@@ -169,6 +169,7 @@ tar.workspace = true
|
||||
flate2.workspace = true
|
||||
backon = {workspace = true, optional = true}
|
||||
strum = { workspace = true, optional = true }
|
||||
dashmap.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
deno_core = { workspace = true, optional = true }
|
||||
|
||||
@@ -1310,6 +1310,20 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/settings/get_stats:
|
||||
get:
|
||||
summary: get encrypted telemetry stats (EE only)
|
||||
operationId: getStats
|
||||
tags:
|
||||
- setting
|
||||
responses:
|
||||
"200":
|
||||
description: base64-encoded encrypted telemetry blob
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/settings/latest_key_renewal_attempt:
|
||||
get:
|
||||
summary: get latest key renewal attempt
|
||||
@@ -2383,6 +2397,9 @@ paths:
|
||||
type: string
|
||||
operator_settings:
|
||||
$ref: "#/components/schemas/OperatorSettings"
|
||||
public_app_execution_limit_per_minute:
|
||||
type: integer
|
||||
description: Rate limit for public app executions per minute per server. NULL or 0 means disabled.
|
||||
|
||||
/w/{workspace}/workspaces/get_deploy_to:
|
||||
get:
|
||||
@@ -4152,6 +4169,35 @@ paths:
|
||||
type: string
|
||||
example: "Updated mute critical alert UI settings for workspace: workspace_id"
|
||||
|
||||
/w/{workspace}/workspaces/public_app_rate_limit:
|
||||
post:
|
||||
summary: Set public app rate limit for this workspace
|
||||
operationId: setPublicAppRateLimit
|
||||
tags:
|
||||
- setting
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: Public app rate limit configuration
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
public_app_execution_limit_per_minute:
|
||||
type: integer
|
||||
description: Rate limit for public app executions per minute per server. NULL or 0 to disable.
|
||||
example: 100
|
||||
responses:
|
||||
"200":
|
||||
description: Successfully updated public app rate limit settings.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
example: "Updated public app rate limit for workspace: workspace_id"
|
||||
|
||||
/oauth/login_callback/{client_name}:
|
||||
post:
|
||||
security: []
|
||||
@@ -5403,6 +5449,34 @@ paths:
|
||||
required:
|
||||
- app
|
||||
|
||||
/apps/hub/get_raw/{id}:
|
||||
get:
|
||||
summary: get hub raw app by id
|
||||
operationId: getHubRawAppById
|
||||
tags:
|
||||
- app
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/PathId"
|
||||
responses:
|
||||
"200":
|
||||
description: raw app
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
app:
|
||||
type: object
|
||||
properties:
|
||||
summary:
|
||||
type: string
|
||||
value: {}
|
||||
required:
|
||||
- summary
|
||||
- value
|
||||
required:
|
||||
- app
|
||||
|
||||
/apps_u/public_app_by_custom_path/{custom_path}:
|
||||
get:
|
||||
summary: get public app by custom path
|
||||
|
||||
@@ -126,6 +126,7 @@ pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
.route("/hub/list", get(list_hub_apps))
|
||||
.route("/hub/get/:id", get(get_hub_app_by_id))
|
||||
.route("/hub/get_raw/:id", get(get_hub_raw_app_by_id))
|
||||
}
|
||||
|
||||
#[derive(FromRow, Deserialize, Serialize)]
|
||||
@@ -1312,6 +1313,24 @@ pub async fn get_hub_app_by_id(
|
||||
Ok(Json(value))
|
||||
}
|
||||
|
||||
pub async fn get_hub_raw_app_by_id(
|
||||
Path(id): Path<i32>,
|
||||
Extension(db): Extension<DB>,
|
||||
) -> JsonResult<Box<serde_json::value::RawValue>> {
|
||||
let value = http_get_from_hub(
|
||||
&HTTP_CLIENT,
|
||||
&format!("{}/raw_apps/{}/json", *HUB_BASE_URL.read().await, id),
|
||||
false,
|
||||
None,
|
||||
Some(&db),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
Ok(Json(value))
|
||||
}
|
||||
|
||||
async fn delete_app(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -1910,6 +1929,15 @@ async fn execute_component(
|
||||
}
|
||||
};
|
||||
|
||||
// Check rate limit for anonymous (public) executions
|
||||
if matches!(policy.execution_mode, ExecutionMode::Anonymous) && opt_authed.is_none() {
|
||||
if let Some(limit) = crate::workspaces::get_public_app_rate_limit(&db, &w_id).await? {
|
||||
if limit > 0 {
|
||||
crate::public_app_rate_limit::check_and_increment(&w_id, limit)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execution is publisher and an user is authenticated: check if the user is authorized to
|
||||
// execute the app.
|
||||
if let (ExecutionMode::Publisher, Some(authed)) = (policy.execution_mode, opt_authed.as_ref()) {
|
||||
|
||||
@@ -167,6 +167,7 @@ mod teams_approvals_oss;
|
||||
#[cfg(feature = "native_trigger")]
|
||||
pub mod native_triggers;
|
||||
mod public_app_layer;
|
||||
mod public_app_rate_limit;
|
||||
mod static_assets;
|
||||
#[cfg(all(feature = "stripe", feature = "enterprise", feature = "private"))]
|
||||
pub mod stripe_ee;
|
||||
|
||||
48
backend/windmill-api/src/public_app_rate_limit.rs
Normal file
48
backend/windmill-api/src/public_app_rate_limit.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Author: Windmill Labs, Inc
|
||||
* Copyright: Windmill Labs, Inc 2024
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use chrono::Utc;
|
||||
use dashmap::DashMap;
|
||||
use hyper::StatusCode;
|
||||
use std::sync::LazyLock;
|
||||
use windmill_common::error::{Error, Result};
|
||||
|
||||
struct RateLimitEntry {
|
||||
count: i32,
|
||||
minute_bucket: i64,
|
||||
}
|
||||
|
||||
static RATE_LIMIT_COUNTER: LazyLock<DashMap<String, RateLimitEntry>> =
|
||||
LazyLock::new(DashMap::new);
|
||||
|
||||
pub fn check_and_increment(workspace_id: &str, limit: i32) -> Result<()> {
|
||||
let current_minute = Utc::now().timestamp() / 60;
|
||||
|
||||
let mut entry = RATE_LIMIT_COUNTER
|
||||
.entry(workspace_id.to_string())
|
||||
.or_insert(RateLimitEntry { count: 0, minute_bucket: current_minute });
|
||||
|
||||
if entry.minute_bucket != current_minute {
|
||||
entry.count = 0;
|
||||
entry.minute_bucket = current_minute;
|
||||
}
|
||||
|
||||
if entry.count >= limit {
|
||||
return Err(Error::Generic(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
format!(
|
||||
"Rate limit exceeded for public app executions in workspace '{}'. \
|
||||
Limit: {} per minute per server.",
|
||||
workspace_id, limit
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
entry.count += 1;
|
||||
Ok(())
|
||||
}
|
||||
@@ -58,6 +58,7 @@ pub fn global_service() -> Router {
|
||||
.route("/test_smtp", post(test_email))
|
||||
.route("/test_license_key", post(test_license_key))
|
||||
.route("/send_stats", post(send_stats))
|
||||
.route("/get_stats", get(get_stats))
|
||||
.route(
|
||||
"/latest_key_renewal_attempt",
|
||||
get(get_latest_key_renewal_attempt),
|
||||
@@ -434,6 +435,25 @@ pub async fn send_stats(Extension(db): Extension<DB>, authed: ApiAuthed) -> Resu
|
||||
Ok("Sent stats".to_string())
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
pub async fn get_stats(Extension(db): Extension<DB>, authed: ApiAuthed) -> Result<String> {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
let stats = windmill_common::stats_oss::get_stats_payload(
|
||||
&db,
|
||||
&windmill_common::stats_oss::SendStatsReason::Manual,
|
||||
)
|
||||
.await?;
|
||||
let encrypted = windmill_common::stats_oss::encrypt_stats(&stats)?;
|
||||
Ok(encrypted)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
pub async fn get_stats() -> Result<String> {
|
||||
Err(error::Error::BadRequest(
|
||||
"Downloading telemetry is only available on enterprise edition".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct KeyRenewalAttempt {
|
||||
result: String,
|
||||
|
||||
@@ -176,6 +176,10 @@ pub fn workspaced_service() -> Router {
|
||||
post(acknowledge_all_critical_alerts),
|
||||
)
|
||||
.route("/critical_alerts/mute", post(mute_critical_alerts))
|
||||
.route(
|
||||
"/public_app_rate_limit",
|
||||
post(edit_public_app_rate_limit),
|
||||
)
|
||||
.route("/operator_settings", post(update_operator_settings))
|
||||
.route(
|
||||
"/create_workspace_fork_branch",
|
||||
@@ -287,6 +291,8 @@ pub struct WorkspaceSettings {
|
||||
pub error_handler: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub success_handler: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub public_app_execution_limit_per_minute: Option<i32>,
|
||||
}
|
||||
|
||||
/// #[derive(sqlx::Type, Serialize, Deserialize, Debug)]
|
||||
@@ -625,7 +631,8 @@ async fn get_settings(
|
||||
git_app_installations,
|
||||
auto_invite,
|
||||
error_handler,
|
||||
success_handler
|
||||
success_handler,
|
||||
public_app_execution_limit_per_minute
|
||||
FROM
|
||||
workspace_settings
|
||||
WHERE
|
||||
@@ -4401,6 +4408,55 @@ pub async fn mute_critical_alerts() -> Error {
|
||||
Error::NotFound("Critical Alerts require EE".to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EditPublicAppRateLimitRequest {
|
||||
pub public_app_execution_limit_per_minute: Option<i32>,
|
||||
}
|
||||
|
||||
async fn edit_public_app_rate_limit(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
authed: ApiAuthed,
|
||||
Json(req): Json<EditPublicAppRateLimitRequest>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET public_app_execution_limit_per_minute = $1 WHERE workspace_id = $2",
|
||||
req.public_app_execution_limit_per_minute,
|
||||
&w_id
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
DeployedObject::Settings { setting_type: "public_app_rate_limit".to_string() },
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(format!(
|
||||
"Updated public app rate limit for workspace: {}",
|
||||
&w_id
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_public_app_rate_limit(db: &DB, w_id: &str) -> Result<Option<i32>> {
|
||||
let result: Option<Option<i32>> = sqlx::query_scalar(
|
||||
"SELECT public_app_execution_limit_per_minute FROM workspace_settings WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(w_id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
Ok(result.flatten())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct ChangeOperatorSettings {
|
||||
#[serde(default)]
|
||||
|
||||
@@ -61,6 +61,8 @@ regex.workspace = true
|
||||
git-version.workspace = true
|
||||
cron.workspace = true
|
||||
magic-crypt.workspace = true
|
||||
rsa.workspace = true
|
||||
aes-gcm.workspace = true
|
||||
object_store = { workspace = true, optional = true }
|
||||
prometheus = { workspace = true, optional = true }
|
||||
aws-config.workspace = true
|
||||
|
||||
@@ -50,3 +50,19 @@ pub async fn get_user_usage<'c, E: sqlx::Executor<'c, Database = Postgres>>(
|
||||
let usage = ActiveUserUsage { author_count: None, operator_count: None };
|
||||
Ok(usage)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct Stats {}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub async fn get_stats_payload(_db: &DB, _include_job_usage: bool, _manual: bool) -> Result<Stats> {
|
||||
// stats details are closed source
|
||||
Ok(Stats {})
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn encrypt_stats(_stats: &Stats) -> Result<String> {
|
||||
// stats details are closed source
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
90
frontend/package-lock.json
generated
90
frontend/package-lock.json
generated
@@ -47,6 +47,7 @@
|
||||
"hash-sum": "^2.0.0",
|
||||
"highlight.js": "^11.8.0",
|
||||
"idb": "^8.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"lru-cache": "^11.1.0",
|
||||
"lucide-svelte": "^0.540.0",
|
||||
"minimatch": "^10.0.1",
|
||||
@@ -4300,6 +4301,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
"version": "8.3.6",
|
||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
|
||||
@@ -6902,7 +6909,6 @@
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"devOptional": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
@@ -7102,6 +7108,12 @@
|
||||
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -7302,6 +7314,54 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/jszip/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -7542,6 +7602,21 @@
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lie/node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
@@ -10744,6 +10819,12 @@
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
||||
@@ -11549,6 +11630,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -13282,7 +13369,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/validate-npm-package-license": {
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
"hash-sum": "^2.0.0",
|
||||
"highlight.js": "^11.8.0",
|
||||
"idb": "^8.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"lru-cache": "^11.1.0",
|
||||
"lucide-svelte": "^0.540.0",
|
||||
"minimatch": "^10.0.1",
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
{/snippet}
|
||||
|
||||
<!-- {JSON.stringify($values, null, 2)} -->
|
||||
{#if (!setting.cloudonly || isCloudHosted()) && showSetting(setting.key, $values) && !(setting.hiddenIfNull && $values[setting.key] == null) && !(setting.hiddenIfEmpty && !$values[setting.key])}
|
||||
{#if (!setting.cloudonly || isCloudHosted()) && showSetting(setting.key, $values) && !(setting.hiddenIfNull && $values[setting.key] == null) && !(setting.hiddenIfEmpty && !$values[setting.key]) && !(setting.hidden_in_ee && $enterpriseLicense)}
|
||||
{#if setting.fieldType == 'select'}
|
||||
<div>
|
||||
{@render LabelSnippet()}
|
||||
|
||||
@@ -231,6 +231,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
let downloadingStats = $state(false)
|
||||
async function downloadStats() {
|
||||
try {
|
||||
downloadingStats = true
|
||||
const encryptedData = await SettingService.getStats()
|
||||
const blob = new Blob([encryptedData], { type: 'application/octet-stream' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `windmill-telemetry-${new Date().toISOString().split('T')[0]}.enc`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
sendUserToast('Telemetry data downloaded')
|
||||
} catch (err) {
|
||||
throw err
|
||||
} finally {
|
||||
downloadingStats = false
|
||||
}
|
||||
}
|
||||
|
||||
function isValidTeamsChannel(value: any): value is TeamsChannel {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
@@ -341,18 +363,29 @@
|
||||
<div class="text-primary pb-4 text-xs">
|
||||
On Enterprise Edition, you must send data to check that usage is in line with the
|
||||
terms of the subscription. You can either enable telemetry or regularly send usage
|
||||
data by clicking the button below.
|
||||
data by clicking the button below. For air-gapped instances, you can download the
|
||||
telemetry data and send it manually.
|
||||
</div>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<Button
|
||||
on:click={sendStats}
|
||||
variant="default"
|
||||
btnClasses="w-auto"
|
||||
loading={sendingStats}
|
||||
size="xs"
|
||||
>
|
||||
Send usage
|
||||
</Button>
|
||||
<Button
|
||||
on:click={downloadStats}
|
||||
variant="default"
|
||||
btnClasses="w-auto"
|
||||
loading={downloadingStats}
|
||||
size="xs"
|
||||
>
|
||||
Download usage
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
on:click={sendStats}
|
||||
variant="default"
|
||||
btnClasses="w-auto"
|
||||
wrapperClasses="mb-4"
|
||||
loading={sendingStats}
|
||||
size="xs"
|
||||
>
|
||||
Send usage
|
||||
</Button>
|
||||
{/if}
|
||||
{:else if category == 'Auth/OAuth/SAML'}
|
||||
<AuthSettings
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface Setting {
|
||||
}
|
||||
hiddenIfNull?: boolean
|
||||
hiddenIfEmpty?: boolean
|
||||
hidden_in_ee?: boolean
|
||||
requiresReloadOnChange?: boolean
|
||||
isValid?: (value: any) => boolean
|
||||
error?: string
|
||||
@@ -496,7 +497,8 @@ export const settings: Record<string, Setting[]> = {
|
||||
label: 'Disable telemetry',
|
||||
key: 'disable_stats',
|
||||
fieldType: 'boolean',
|
||||
storage: 'setting'
|
||||
storage: 'setting',
|
||||
hidden_in_ee: true
|
||||
}
|
||||
],
|
||||
'Secret Storage': [
|
||||
|
||||
@@ -4,13 +4,17 @@
|
||||
import UndoRedo from '$lib/components/common/button/UndoRedo.svelte'
|
||||
|
||||
import { AppService, DraftService, type Policy } from '$lib/gen'
|
||||
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
|
||||
import { rawAppToHubUrl } from '$lib/hub'
|
||||
import { enterpriseLicense, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores'
|
||||
import JSZip from 'jszip'
|
||||
import YAML from 'yaml'
|
||||
import {
|
||||
Bug,
|
||||
DiffIcon,
|
||||
Download,
|
||||
EllipsisVertical,
|
||||
FileJson,
|
||||
FileUp,
|
||||
Globe,
|
||||
History,
|
||||
Pen,
|
||||
Save,
|
||||
@@ -143,8 +147,40 @@
|
||||
let draftDrawerOpen = $state(false)
|
||||
let saveDrawerOpen = $state(false)
|
||||
let historyBrowserDrawerOpen = $state(false)
|
||||
let publishToHubDrawerOpen = $state(false)
|
||||
let publishingToHub = $state(false)
|
||||
let deploymentMsg: string | undefined = $state(undefined)
|
||||
|
||||
async function publishToHub() {
|
||||
if (!app) return
|
||||
publishingToHub = true
|
||||
try {
|
||||
const { js, css } = await getBundle()
|
||||
const zip = new JSZip()
|
||||
zip.file('app.yaml', YAML.stringify(app))
|
||||
zip.file('bundle.js', js)
|
||||
zip.file('bundle.css', css)
|
||||
const blob = await zip.generateAsync({ type: 'blob' })
|
||||
|
||||
// Download the zip
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${(appPath || 'raw-app').replaceAll('/', '__')}.zip`
|
||||
a.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||
|
||||
// Open hub page
|
||||
const hubUrl = rawAppToHubUrl(
|
||||
$hubBaseUrlStore,
|
||||
summary || appPath.split('/').pop()?.replace('_', ' ') || 'my raw app'
|
||||
)
|
||||
window.open(hubUrl.toString(), '_blank')
|
||||
} finally {
|
||||
publishingToHub = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeSaveDrawer() {
|
||||
saveDrawerOpen = false
|
||||
}
|
||||
@@ -538,11 +574,10 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Hub compatible JSON',
|
||||
icon: FileUp,
|
||||
displayName: 'Publish to Hub',
|
||||
icon: Globe,
|
||||
action: () => {
|
||||
sendUserToast('todo')
|
||||
// appExport.open(toStatic(app, $staticExporter, summary).app)
|
||||
publishToHubDrawerOpen = true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -728,6 +763,42 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer bind:open={publishToHubDrawerOpen} size="600px">
|
||||
<DrawerContent title="Publish to Hub" on:close={() => (publishToHubDrawerOpen = false)}>
|
||||
{#snippet actions()}
|
||||
<Button
|
||||
loading={publishingToHub}
|
||||
disabled={!app}
|
||||
on:click={publishToHub}
|
||||
variant="accent"
|
||||
startIcon={{ icon: Download }}
|
||||
>
|
||||
Download & open hub
|
||||
</Button>
|
||||
{/snippet}
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-secondary text-sm">
|
||||
This will download a zip file containing your raw app bundle and open the Windmill Hub
|
||||
submission page.
|
||||
</p>
|
||||
<div class="text-sm">
|
||||
<p class="font-semibold mb-2">The zip file will contain:</p>
|
||||
<ul class="list-disc list-inside text-secondary space-y-1">
|
||||
<li
|
||||
><code class="text-xs bg-surface-secondary px-1 rounded">app.yaml</code> - App configuration</li
|
||||
>
|
||||
<li
|
||||
><code class="text-xs bg-surface-secondary px-1 rounded">bundle.js</code> - JavaScript bundle</li
|
||||
>
|
||||
<li
|
||||
><code class="text-xs bg-surface-secondary px-1 rounded">bundle.css</code> - CSS styles</li
|
||||
>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<AppJobsDrawer
|
||||
bind:open={jobsDrawerOpen}
|
||||
on:clear={() => {
|
||||
|
||||
@@ -83,6 +83,14 @@ export function appToHubUrl(staticApp: any, hubBaseUrl: string): URL {
|
||||
return url
|
||||
}
|
||||
|
||||
export function rawAppToHubUrl(hubBaseUrl: string, summary?: string): URL {
|
||||
const url = new URL(hubBaseUrl + '/raw_apps/add')
|
||||
if (summary) {
|
||||
url.searchParams.append('summary', summary)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
type HubPaths = {
|
||||
gitSync: string
|
||||
gitSyncTest: string
|
||||
|
||||
@@ -39,13 +39,14 @@
|
||||
let nodraft = $page.url.searchParams.get('nodraft')
|
||||
const templatePath = $page.url.searchParams.get('template')
|
||||
const templateId = $page.url.searchParams.get('template_id')
|
||||
const hubId = $page.url.searchParams.get('hub')
|
||||
|
||||
const importRaw = $importStore
|
||||
if ($importStore) {
|
||||
$importStore = undefined
|
||||
}
|
||||
|
||||
const appState = nodraft ? undefined : localStorage.getItem('rawapp')
|
||||
const appState = nodraft || hubId ? undefined : localStorage.getItem('rawapp')
|
||||
|
||||
let summary = $state('')
|
||||
let files: Record<string, string> = $state(react19Template)
|
||||
@@ -143,7 +144,18 @@
|
||||
console.log('App loaded from template id')
|
||||
sendUserToast('App loaded from template')
|
||||
goto('?', { replaceState: true })
|
||||
} else if (!templatePath && appState) {
|
||||
} else if (hubId) {
|
||||
const hub = await AppService.getHubRawAppById({ id: Number(hubId) })
|
||||
if (hub.app?.value) {
|
||||
extractValue(hub.app.value)
|
||||
}
|
||||
if (hub.app?.summary) {
|
||||
summary = hub.app.summary
|
||||
}
|
||||
console.log('App loaded from Hub')
|
||||
sendUserToast('App loaded from Hub')
|
||||
goto('?', { replaceState: true })
|
||||
} else if (!templatePath && !hubId && appState) {
|
||||
console.log('App loaded from browser stored autosave')
|
||||
sendUserToast('App restored from browser stored autosave', false, [
|
||||
{
|
||||
|
||||
@@ -102,6 +102,8 @@
|
||||
let successHandlerScriptPath: string | undefined = $state(undefined)
|
||||
let criticalAlertUIMuted: boolean | undefined = $state(undefined)
|
||||
let initialCriticalAlertUIMuted: boolean | undefined = $state(undefined)
|
||||
let publicAppRateLimitPerMinute: number | undefined = $state(undefined)
|
||||
let initialPublicAppRateLimitPerMinute: number | undefined = $state(undefined)
|
||||
|
||||
let aiProviders: Exclude<AIConfig['providers'], undefined> = $state({})
|
||||
let codeCompletionModel: string | undefined = $state(undefined)
|
||||
@@ -347,6 +349,8 @@
|
||||
errorHandlerMutedOnUserPath = errorHandler?.muted_on_user_path
|
||||
criticalAlertUIMuted = settings.mute_critical_alerts
|
||||
initialCriticalAlertUIMuted = settings.mute_critical_alerts
|
||||
publicAppRateLimitPerMinute = settings.public_app_execution_limit_per_minute ?? undefined
|
||||
initialPublicAppRateLimitPerMinute = settings.public_app_execution_limit_per_minute ?? undefined
|
||||
if (emptyString($enterpriseLicense)) {
|
||||
errorHandlerSelected = 'custom'
|
||||
} else {
|
||||
@@ -561,6 +565,21 @@
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
async function editPublicAppRateLimit() {
|
||||
await SettingService.setPublicAppRateLimit({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
public_app_execution_limit_per_minute: publicAppRateLimitPerMinute
|
||||
}
|
||||
})
|
||||
initialPublicAppRateLimitPerMinute = publicAppRateLimitPerMinute
|
||||
sendUserToast(
|
||||
publicAppRateLimitPerMinute
|
||||
? `Public app rate limit set to ${publicAppRateLimitPerMinute} per minute per server`
|
||||
: `Public app rate limit disabled`
|
||||
)
|
||||
}
|
||||
|
||||
// Function to check if there are unsaved changes in AI settings
|
||||
function getAiSettingsInitialAndModifiedValues() {
|
||||
// Only check for unsaved changes when on the AI tab
|
||||
@@ -764,9 +783,9 @@
|
||||
<Tab
|
||||
small
|
||||
value="default_app"
|
||||
aiId="workspace-settings-default-app"
|
||||
aiDescription="Default app workspace settings"
|
||||
label="Default App"
|
||||
aiId="workspace-settings-apps"
|
||||
aiDescription="Apps workspace settings"
|
||||
label="Apps"
|
||||
/>
|
||||
|
||||
<Tab
|
||||
@@ -1405,6 +1424,33 @@ export async function main(
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<hr class="border-t my-8" />
|
||||
<Section
|
||||
label="Public App Rate Limiting"
|
||||
description="Limit the number of public (anonymous) app executions per minute per server. Set to 0 or leave empty to disable. This is a per-server limit, not a global limit."
|
||||
class="flex flex-col gap-6"
|
||||
>
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<TextInput
|
||||
inputProps={{ type: 'number', placeholder: '0 (disabled)' }}
|
||||
bind:value={publicAppRateLimitPerMinute}
|
||||
class="w-48"
|
||||
/>
|
||||
<span class="text-secondary text-sm">executions per minute per server</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={publicAppRateLimitPerMinute === initialPublicAppRateLimitPerMinute}
|
||||
size="sm"
|
||||
on:click={editPublicAppRateLimit}
|
||||
variant="default"
|
||||
startIcon={{ icon: Save }}
|
||||
btnClasses="w-fit"
|
||||
>
|
||||
Save rate limit
|
||||
</Button>
|
||||
</Section>
|
||||
{:else if tab == 'native_triggers'}
|
||||
{#if $workspaceStore}
|
||||
{#await import('$lib/components/workspaceSettings/WorkspaceIntegrations.svelte') then { default: WorkspaceIntegrations }}
|
||||
|
||||
Reference in New Issue
Block a user