Compare commits

...

4 Commits

Author SHA1 Message Date
HugoCasa
216432548d feat: air gapped telemetry 2026-02-05 12:20:36 +01:00
HugoCasa
637f6f0a7d sqlx 2026-02-04 17:43:24 +01:00
Ruben Fiszel
1d06c65c55 Merge branch 'main' into hc/hub-raw-apps 2026-02-04 16:32:35 +00:00
HugoCasa
ea965a06f2 feat: public app rate limiting + fork hub raw apps + raw apps publish to hub button 2026-02-04 17:28:20 +01:00
28 changed files with 565 additions and 42 deletions

View File

@@ -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
]
},

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO metrics (id, value) VALUES ('telemetry', $1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb"
]
},
"nullable": []
},
"hash": "0cb84cbb9083d967cc8be1cccab5be61080c1003eef51eea41862b25c2b93de6"
}

View 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
View File

@@ -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",

View File

@@ -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"

View File

@@ -1 +1 @@
88e49a7c9746080a8a95e30828655d5783a616d6
c0196228860b5444c12f7b78eee6c4e151e3a686

View File

@@ -0,0 +1,2 @@
ALTER TABLE workspace_settings
DROP COLUMN IF EXISTS public_app_execution_limit_per_minute;

View File

@@ -0,0 +1,2 @@
ALTER TABLE workspace_settings
ADD COLUMN IF NOT EXISTS public_app_execution_limit_per_minute INTEGER DEFAULT NULL;

View File

@@ -0,0 +1 @@
-- No-op: cannot restore deleted telemetry data

View File

@@ -0,0 +1,2 @@
-- Delete all saved telemetry data from metrics table
DELETE FROM metrics WHERE id = 'telemetry';

View File

@@ -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 }

View File

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

View File

@@ -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()) {

View File

@@ -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;

View 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(())
}

View File

@@ -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,

View File

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

View File

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

View File

@@ -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())
}

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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()}

View File

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

View File

@@ -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': [

View File

@@ -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={() => {

View File

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

View File

@@ -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, [
{

View File

@@ -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 }}