Merge branch 'main' into alp/gh_app_timeout

This commit is contained in:
Alexander Petric
2026-01-26 11:51:02 -05:00
committed by GitHub
83 changed files with 6414 additions and 194 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
build-args: |
features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,license,otel,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,postgres_trigger,gcp_trigger,mqtt_trigger,websocket,smtp,static_frontend,all_languages,deno_core,mcp,private
features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,license,otel,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,postgres_trigger,gcp_trigger,mqtt_trigger,websocket,smtp,native_trigger,static_frontend,all_languages,deno_core,mcp,private
secrets: |
rh_username=${{ secrets.RH_USERNAME }}
rh_password=${{ secrets.RH_PASSWORD }}
@@ -64,7 +64,7 @@ jobs:
platforms: linux/amd64
push: true
build-args: |
features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,license,otel,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,postgres_trigger,gcp_trigger,mqtt_trigger,websocket,smtp,static_frontend,all_languages,deno_core,mcp,private
features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,license,otel,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,postgres_trigger,gcp_trigger,mqtt_trigger,websocket,smtp,native_trigger,static_frontend,all_languages,deno_core,mcp,private
secrets: |
rh_username=${{ secrets.RH_USERNAME }}
rh_password=${{ secrets.RH_PASSWORD }}
@@ -81,7 +81,7 @@ jobs:
platforms: linux/arm64
push: true
build-args: |
features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,license,otel,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,postgres_trigger,gcp_trigger,mqtt_trigger,websocket,smtp,static_frontend,all_languages,deno_core,mcp,private
features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,license,otel,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,postgres_trigger,gcp_trigger,mqtt_trigger,websocket,smtp,native_trigger,static_frontend,all_languages,deno_core,mcp,private
secrets: |
rh_username=${{ secrets.RH_USERNAME }}
rh_password=${{ secrets.RH_PASSWORD }}
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
$env:OPENSSL_DIR="${Env:VCPKG_INSTALLATION_ROOT}\installed\x64-windows-static"
mkdir frontend/build && cd backend
New-Item -Path . -Name "windmill-api/openapi-deref.yaml" -ItemType "File" -Force
cargo build --release --features=enterprise,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,tantivy,license,http_trigger,zip,oauth2,kafka,nats,sqs_trigger,postgres_trigger,gcp_trigger,mqtt_trigger,websocket,smtp,static_frontend,all_languages_windows,mcp,bedrock,private
cargo build --release --features=enterprise,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,tantivy,license,http_trigger,zip,oauth2,kafka,nats,sqs_trigger,postgres_trigger,gcp_trigger,mqtt_trigger,websocket,smtp,native_trigger,static_frontend,all_languages_windows,mcp,bedrock,private
- name: Rename binary with corresponding architecture
run: |
Rename-Item -Path ".\backend\target\release\windmill.exe" -NewName "windmill-ee.exe"
+1 -1
View File
@@ -159,7 +159,7 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
build-args: |
features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,agent_worker_server,tantivy,license,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,otel,dind,postgres_trigger,mqtt_trigger,gcp_trigger,websocket,smtp,static_frontend,all_languages,private,deno_core,mcp,bedrock
features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,agent_worker_server,tantivy,license,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,otel,dind,postgres_trigger,mqtt_trigger,gcp_trigger,websocket,smtp,native_trigger,static_frontend,all_languages,private,deno_core,mcp,bedrock
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }}
${{ steps.meta-ee-public.outputs.tags }}
+1 -1
View File
@@ -59,7 +59,7 @@ jobs:
$env:OPENSSL_DIR="${Env:VCPKG_INSTALLATION_ROOT}\installed\x64-windows-static"
mkdir frontend/build && cd backend
New-Item -Path . -Name "windmill-api/openapi-deref.yaml" -ItemType "File" -Force
cargo build --release --features=enterprise,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,tantivy,license,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,postgres_trigger,mqtt_trigger,gcp_trigger,websocket,smtp,static_frontend,all_languages_windows,mcp,bedrock,private
cargo build --release --features=enterprise,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,tantivy,license,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,postgres_trigger,mqtt_trigger,gcp_trigger,websocket,smtp,native_trigger,static_frontend,all_languages_windows,mcp,bedrock,private
- name: Rename binary with corresponding architecture
run: |
Rename-Item -Path ".\backend\target\release\windmill.exe" -NewName "windmill-ee.exe"
@@ -29,7 +29,8 @@
"postgres",
"sqs",
"gcp",
"mqtt"
"mqtt",
"nextcloud"
]
}
}
@@ -121,7 +121,8 @@
"postgres",
"sqs",
"gcp",
"mqtt"
"mqtt",
"nextcloud"
]
}
}
@@ -39,7 +39,8 @@
"postgres",
"sqs",
"gcp",
"mqtt"
"mqtt",
"nextcloud"
]
}
}
@@ -39,7 +39,8 @@
"sqs",
"mqtt",
"gcp",
"default_email"
"default_email",
"nextcloud"
]
}
}
@@ -23,7 +23,8 @@
"sqs",
"mqtt",
"gcp",
"default_email"
"default_email",
"nextcloud"
]
}
}
@@ -31,7 +31,8 @@
"sqs",
"mqtt",
"gcp",
"default_email"
"default_email",
"nextcloud"
]
}
}
@@ -68,7 +69,8 @@
"sqs",
"mqtt",
"gcp",
"default_email"
"default_email",
"nextcloud"
]
}
}
@@ -244,7 +244,8 @@
"postgres",
"sqs",
"gcp",
"mqtt"
"mqtt",
"nextcloud"
]
}
}
@@ -30,7 +30,8 @@
"sqs",
"mqtt",
"gcp",
"default_email"
"default_email",
"nextcloud"
]
}
}
@@ -39,7 +39,8 @@
"postgres",
"sqs",
"gcp",
"mqtt"
"mqtt",
"nextcloud"
]
}
}
@@ -26,7 +26,8 @@
"sqs",
"mqtt",
"gcp",
"default_email"
"default_email",
"nextcloud"
]
}
}
@@ -34,7 +34,8 @@
"sqs",
"mqtt",
"gcp",
"default_email"
"default_email",
"nextcloud"
]
}
}
@@ -31,7 +31,8 @@
"sqs",
"mqtt",
"gcp",
"default_email"
"default_email",
"nextcloud"
]
}
}
@@ -29,7 +29,8 @@
"postgres",
"sqs",
"gcp",
"mqtt"
"mqtt",
"nextcloud"
]
}
}
@@ -154,7 +154,8 @@
"postgres",
"sqs",
"gcp",
"mqtt"
"mqtt",
"nextcloud"
]
}
}
@@ -184,7 +184,8 @@
"postgres",
"sqs",
"gcp",
"mqtt"
"mqtt",
"nextcloud"
]
}
}
@@ -30,7 +30,8 @@
"sqs",
"mqtt",
"gcp",
"default_email"
"default_email",
"nextcloud"
]
}
}
@@ -104,7 +104,8 @@
"postgres",
"sqs",
"gcp",
"mqtt"
"mqtt",
"nextcloud"
]
}
}
@@ -159,7 +159,8 @@
"postgres",
"sqs",
"gcp",
"mqtt"
"mqtt",
"nextcloud"
]
}
}
@@ -23,7 +23,8 @@
"sqs",
"mqtt",
"gcp",
"default_email"
"default_email",
"nextcloud"
]
}
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS \"websocket_used!\",\n EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS \"http_routes_used!\",\n EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as \"kafka_used!\",\n EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as \"nats_used!\",\n EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS \"postgres_used!\",\n EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS \"mqtt_used!\",\n EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS \"sqs_used!\",\n EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS \"gcp_used!\",\n EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS \"email_used!\"\n ",
"query": "\n SELECT\n EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS \"websocket_used!\",\n EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS \"http_routes_used!\",\n EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as \"kafka_used!\",\n EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as \"nats_used!\",\n EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS \"postgres_used!\",\n EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS \"mqtt_used!\",\n EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS \"sqs_used!\",\n EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS \"gcp_used!\",\n EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS \"email_used!\",\n EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS \"nextcloud_used!\"\n ",
"describe": {
"columns": [
{
@@ -47,6 +47,11 @@
"ordinal": 8,
"name": "email_used!",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "nextcloud_used!",
"type_info": "Bool"
}
],
"parameters": {
@@ -63,8 +68,9 @@
null,
null,
null,
null,
null
]
},
"hash": "d1d9a3184f3470949840cc07414d08641527a4fe329204c65c49ab82d9ee4afb"
"hash": "d14e982c3d74499ec4bc62118e0edf065799eec5cf16f439b5f7568f392e60c3"
}
@@ -24,7 +24,8 @@
"sqs",
"mqtt",
"gcp",
"default_email"
"default_email",
"nextcloud"
]
}
}
@@ -23,7 +23,8 @@
"sqs",
"mqtt",
"gcp",
"default_email"
"default_email",
"nextcloud"
]
}
}
@@ -184,7 +184,8 @@
"postgres",
"sqs",
"gcp",
"mqtt"
"mqtt",
"nextcloud"
]
}
}
@@ -23,7 +23,8 @@
"sqs",
"mqtt",
"gcp",
"default_email"
"default_email",
"nextcloud"
]
}
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM native_trigger WHERE workspace_id = $1 AND script_path = $2 AND is_flow = $3 AND service_name = 'nextcloud'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Bool"
]
},
"nullable": [
null
]
},
"hash": "f43a5c3eb04c04eb7d5f58e27d3fb6b979319a6507f3e317835e08a5ae33c5f3"
}
@@ -104,7 +104,8 @@
"postgres",
"sqs",
"gcp",
"mqtt"
"mqtt",
"nextcloud"
]
}
}
+9
View File
@@ -7,6 +7,15 @@
- The API routes prefixes are all listed in windmill-api/src/lib.rs
- This repository is the open source side of the project. The enterprise files (\*\_ee.rs) are in the `windmill-ee-private` folder (a sibling directory). Those files are symlinked into their corresponding locations within each crate's `src/` directory.
## JSON Handling
- **Prefer `Box<serde_json::value::RawValue>` over `serde_json::Value`** when possible, especially:
- When storing JSON in the database (JSONB columns)
- When passing JSON through without modification
- When the JSON structure doesn't need to be inspected or manipulated
- This avoids unnecessary parsing/serialization overhead and preserves the original JSON format
- Use `serde_json::Value` only when you need to inspect, modify, or construct JSON programmatically
## Adding New Features
1. Update database schema with migration if necessary
+1
View File
@@ -15555,6 +15555,7 @@ dependencies = [
"sha2 0.10.9",
"sql-builder",
"sqlx",
"strum 0.27.2",
"tar",
"tempfile",
"thiserror 2.0.18",
+1
View File
@@ -79,6 +79,7 @@ postgres_trigger = ["windmill-api/postgres_trigger"]
mcp = ["windmill-api/mcp", "windmill-worker/mcp"]
bedrock = ["windmill-api/bedrock", "windmill-worker/bedrock", "windmill-common/bedrock"]
mqtt_trigger = ["windmill-api/mqtt_trigger"]
native_trigger = ["windmill-api/native_trigger"]
sqs_trigger = ["windmill-api/sqs_trigger", "windmill-common/aws_auth", "windmill-api/openidconnect"]
gcp_trigger = ["windmill-api/gcp_trigger"]
smtp = ["windmill-api/smtp", "windmill-common/smtp", "windmill-queue/smtp"]
@@ -0,0 +1,7 @@
-- Add down migration script here
DROP TABLE IF EXISTS native_trigger;
DROP TABLE IF EXISTS workspace_integrations;
DROP TYPE IF EXISTS native_trigger_service;
@@ -0,0 +1,52 @@
-- Add up migration script here
CREATE TYPE native_trigger_service AS ENUM ('nextcloud');
ALTER TYPE TRIGGER_KIND ADD VALUE IF NOT EXISTS 'nextcloud';
ALTER TYPE job_trigger_kind ADD VALUE IF NOT EXISTS 'nextcloud';
CREATE TABLE native_trigger (
external_id VARCHAR(255) NOT NULL,
workspace_id VARCHAR(50) NOT NULL,
service_name native_trigger_service NOT NULL,
script_path VARCHAR(255) NOT NULL,
is_flow BOOLEAN NOT NULL,
webhook_token_prefix VARCHAR(10) NOT NULL,
service_config JSONB,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (external_id, workspace_id, service_name),
CONSTRAINT fk_native_trigger_workspace FOREIGN KEY (workspace_id)
REFERENCES workspace(id) ON DELETE CASCADE
);
CREATE INDEX idx_native_trigger_workspace
ON native_trigger (workspace_id);
CREATE INDEX idx_native_trigger_script_path
ON native_trigger (workspace_id, script_path, is_flow);
GRANT ALL ON native_trigger TO windmill_user;
GRANT ALL ON native_trigger TO windmill_admin;
CREATE TABLE workspace_integrations (
workspace_id VARCHAR(50) NOT NULL,
service_name native_trigger_service NOT NULL,
oauth_data JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by VARCHAR(50) NOT NULL,
PRIMARY KEY (workspace_id, service_name),
CONSTRAINT fk_workspace_integrations_workspace FOREIGN KEY (workspace_id)
REFERENCES workspace(id) ON DELETE CASCADE
);
CREATE INDEX idx_workspace_integrations_workspace
ON workspace_integrations (workspace_id);
CREATE INDEX idx_workspace_integrations_service
ON workspace_integrations (service_name);
GRANT ALL ON workspace_integrations TO windmill_user;
GRANT ALL ON workspace_integrations TO windmill_admin;
+32 -1
View File
@@ -29,6 +29,9 @@ use windmill_api::{
SCIM_TOKEN,
};
#[cfg(feature = "native_trigger")]
use windmill_api::native_triggers::sync::sync_all_triggers;
#[cfg(feature = "enterprise")]
use windmill_common::ee_oss::low_disk_alerts;
#[cfg(feature = "enterprise")]
@@ -1867,6 +1870,34 @@ pub async fn monitor_db(
windmill_common::min_version::update_min_version(conn, _worker_mode, WORKERS_NAMES.read().await.clone(), initial_load).await;
};
// Run every 5 minutes (10 iterations * 30s = 5 minutes)
let native_triggers_sync_f = async {
#[cfg(feature = "native_trigger")]
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(10) {
if let Some(db) = conn.as_sql() {
match sync_all_triggers(db).await {
Ok(result) => {
tracing::debug!(
"Native triggers sync completed: {} workspaces, {} synced, {} errors",
result.workspaces_processed,
result.total_synced,
result.total_errors
);
if result.total_errors > 0 {
tracing::warn!(
"Native triggers sync encountered {} errors",
result.total_errors
);
}
}
Err(e) => {
tracing::error!("Error during native triggers sync: {:#}", e);
}
}
}
}
};
join!(
expired_items_f,
zombie_jobs_f,
@@ -1885,6 +1916,7 @@ pub async fn monitor_db(
cleanup_debounce_keys_completed_f,
cleanup_flow_iterator_data_f,
cleanup_worker_group_stats_f,
native_triggers_sync_f,
);
}
@@ -2099,7 +2131,6 @@ pub async fn load_base_url(conn: &Connection) -> error::Result<String> {
} else {
std_base_url
};
{
let mut l = BASE_URL.write().await;
*l = base_url.clone();
+2
View File
@@ -32,6 +32,7 @@ http_trigger = ["dep:matchit", "dep:thiserror", "dep:sha1", "dep:constant_time_e
static_frontend = ["dep:rust-embed"]
postgres_trigger = ["dep:rust-postgres", "dep:pg_escape", "dep:byteorder", "dep:thiserror", "dep:rust_decimal", "dep:rust-postgres-native-tls"]
mqtt_trigger = ["dep:thiserror", "dep:rumqttc"]
native_trigger = ["dep:strum", "dep:backon", "oauth2"]
sqs_trigger = ["dep:aws-sdk-sqs", "dep:aws-sdk-sts", "dep:aws-sdk-sso", "dep:aws-sdk-ssooidc", "dep:thiserror", "dep:backon", "dep:aws-config"]
deno_core = ["dep:deno_core", "dep:deno_error"]
gcp_trigger = ["dep:thiserror", "dep:google-cloud-pubsub", "dep:google-cloud-googleapis", "dep:tonic"]
@@ -166,6 +167,7 @@ deno_core = { workspace = true, optional = true }
tar.workspace = true
flate2.workspace = true
backon = {workspace = true, optional = true}
strum = { workspace = true, optional = true }
[build-dependencies]
deno_core = { workspace = true, optional = true }
+634
View File
@@ -3548,6 +3548,8 @@ paths:
type: boolean
email_used:
type: boolean
nextcloud_used:
type: boolean
required:
- http_routes_used
- websocket_used
@@ -3558,6 +3560,7 @@ paths:
- gcp_used
- sqs_used
- email_used
- nextcloud_used
/w/{workspace}/users/list:
get:
summary: list users
@@ -11946,6 +11949,424 @@ paths:
schema:
type: string
/w/{workspace}/native_triggers/integrations/list:
get:
summary: list available native trigger services
operationId: listNativeTriggerServices
tags:
- workspace_integration
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: native trigger services list
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/WorkspaceIntegrations"
/w/{workspace}/native_triggers/integrations/{service_name}/exists:
get:
summary: check if integrations for a particular service exists
operationId: checkIfNativeTriggersServiceExists
tags:
- workspace_integration
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: service_name
in: path
required: true
schema:
$ref: "#/components/schemas/NativeServiceName"
responses:
"200":
description: integration exists
content:
application/json:
schema:
type: boolean
/w/{workspace}/native_triggers/integrations/{service_name}/create:
post:
summary: create native trigger service
operationId: createNativeTriggerService
tags:
- workspace_integration
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: service_name
in: path
required: true
schema:
$ref: "#/components/schemas/NativeServiceName"
requestBody:
description: new native trigger service
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/WorkspaceOAuthConfig"
responses:
"201":
description: native trigger service created
content:
text/plain:
schema:
type: string
/w/{workspace}/native_triggers/integrations/{service_name}/generate_connect_url:
post:
summary: generate connect url for native trigger service
operationId: generateNativeTriggerServiceConnectUrl
tags:
- workspace_integration
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: service_name
in: path
required: true
schema:
$ref: "#/components/schemas/NativeServiceName"
requestBody:
description: redirect_uri
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RedirectUri"
responses:
"200":
description: native trigger service connect url
content:
application/json:
schema:
type: string
/w/{workspace}/native_triggers/integrations/{service_name}/delete:
delete:
summary: delete native trigger service
operationId: deleteNativeTriggerService
tags:
- workspace_integration
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: service_name
in: path
required: true
schema:
$ref: "#/components/schemas/NativeServiceName"
responses:
"200":
description: native trigger service deleted
content:
text/plain:
schema:
type: string
/w/{workspace}/native_triggers/integrations/{service_name}/callback/{code}/{state}:
post:
summary: native trigger service oauth callback
operationId: nativeTriggerServiceCallback
tags:
- workspace_integration
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: service_name
in: path
required: true
schema:
$ref: "#/components/schemas/NativeServiceName"
- name: code
in: path
required: true
schema:
type: string
- name: state
in: path
required: true
schema:
type: string
requestBody:
description: redirect_uri
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RedirectUri"
responses:
"200":
description: native trigger service oauth completed
content:
text/plain:
schema:
type: string
/w/{workspace}/native_triggers/{service_name}/create:
post:
summary: create native trigger
description: |
Creates a new native trigger for the specified service.
Requires write access to the script or flow that the trigger will be associated with.
operationId: createNativeTrigger
tags:
- native_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: service_name
in: path
required: true
schema:
$ref: "#/components/schemas/NativeServiceName"
requestBody:
description: new native trigger configuration
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/NativeTriggerData"
responses:
"201":
description: native trigger created
content:
application/json:
schema:
$ref: "#/components/schemas/CreateTriggerResponse"
/w/{workspace}/native_triggers/{service_name}/update/{external_id}:
post:
summary: update native trigger
description: |
Updates an existing native trigger.
Requires write access to the script or flow that the trigger is associated with.
operationId: updateNativeTrigger
tags:
- native_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: service_name
in: path
required: true
schema:
$ref: "#/components/schemas/NativeServiceName"
- name: external_id
in: path
required: true
description: The external ID of the trigger from the external service
schema:
type: string
requestBody:
description: updated native trigger configuration
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/NativeTriggerData"
responses:
"200":
description: native trigger updated
content:
text/plain:
schema:
type: string
/w/{workspace}/native_triggers/{service_name}/get/{external_id}:
get:
summary: get native trigger
description: |
Retrieves a native trigger by its external ID.
Requires write access to the script or flow that the trigger is associated with.
operationId: getNativeTrigger
tags:
- native_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: service_name
in: path
required: true
schema:
$ref: "#/components/schemas/NativeServiceName"
- name: external_id
in: path
required: true
description: The external ID of the trigger from the external service
schema:
type: string
responses:
"200":
description: native trigger with external configuration
content:
application/json:
schema:
$ref: "#/components/schemas/NativeTriggerWithExternal"
/w/{workspace}/native_triggers/{service_name}/delete/{external_id}:
delete:
summary: delete native trigger
description: |
Deletes a native trigger by its external ID.
Requires write access to the script or flow that the trigger is associated with.
operationId: deleteNativeTrigger
tags:
- native_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: service_name
in: path
required: true
schema:
$ref: "#/components/schemas/NativeServiceName"
- name: external_id
in: path
required: true
description: The external ID of the trigger from the external service
schema:
type: string
responses:
"200":
description: native trigger deleted
content:
text/plain:
schema:
type: string
/w/{workspace}/native_triggers/{service_name}/list:
get:
summary: list native triggers
description: Lists all native triggers for the specified service in the workspace.
operationId: listNativeTriggers
tags:
- native_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: service_name
in: path
required: true
schema:
$ref: "#/components/schemas/NativeServiceName"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
responses:
"200":
description: native triggers list
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/NativeTrigger"
/w/{workspace}/native_triggers/{service_name}/exists/{external_id}:
get:
summary: check if native trigger exists
description: Checks if a native trigger with the given external ID exists.
operationId: existsNativeTrigger
tags:
- native_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: service_name
in: path
required: true
schema:
$ref: "#/components/schemas/NativeServiceName"
- name: external_id
in: path
required: true
description: The external ID of the trigger from the external service
schema:
type: string
responses:
"200":
description: whether the native trigger exists
content:
application/json:
schema:
type: boolean
/w/{workspace}/native_triggers/{service_name}/sync:
post:
summary: sync native triggers with external service
operationId: syncNativeTriggers
tags:
- native_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: service_name
in: path
required: true
schema:
$ref: "#/components/schemas/NativeServiceName"
responses:
"200":
description: sync completed successfully
/w/{workspace}/native_triggers/nextcloud/events:
get:
summary: list available NextCloud events
operationId: listNextCloudEvents
tags:
- native_trigger
parameters:
- name: workspace
in: path
required: true
schema:
type: string
responses:
"200":
description: list of available NextCloud events
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/NextCloudEventType"
/native_triggers/{service_name}/w/{workspace_id}/webhook/{internal_id}:
post:
summary: receive webhook from external native trigger service
operationId: nativeTriggerWebhook
tags:
- native_trigger
parameters:
- name: service_name
in: path
required: true
schema:
$ref: "#/components/schemas/NativeServiceName"
- name: workspace_id
in: path
required: true
schema:
type: string
- name: internal_id
in: path
required: true
schema:
type: integer
format: int64
description: The internal database ID of the trigger
requestBody:
description: webhook payload from external service
required: false
content:
application/json:
schema:
type: object
additionalProperties: true
text/plain:
schema:
type: string
responses:
"200":
description: webhook received successfully
content:
text/plain:
schema:
type: string
/w/{workspace}/mqtt_triggers/create:
post:
summary: create mqtt trigger
@@ -19398,6 +19819,8 @@ components:
type: number
sqs_count:
type: number
nextcloud_count:
type: number
WebsocketTrigger:
allOf:
@@ -22045,3 +22468,214 @@ components:
kind:
$ref: "#/components/schemas/AssetKind"
required: [path, kind]
NativeServiceName:
type: string
enum:
- nextcloud
NativeTrigger:
type: object
description: A native trigger stored in Windmill
properties:
external_id:
type: string
description: The unique identifier from the external service
workspace_id:
type: string
description: The workspace this trigger belongs to
service_name:
$ref: "#/components/schemas/NativeServiceName"
script_path:
type: string
description: The path to the script or flow that will be triggered
is_flow:
type: boolean
description: Whether the trigger targets a flow (true) or a script (false)
service_config:
type: object
description: Configuration for the trigger including event_type and service_config
additionalProperties: true
error:
type: string
nullable: true
description: Error message if the trigger is in an error state
required:
- external_id
- workspace_id
- service_name
- script_path
- is_flow
- service_config
NativeTriggerWithExternal:
type: object
description: Full trigger response containing both Windmill data and external service data
properties:
external_id:
type: string
description: The unique identifier from the external service
workspace_id:
type: string
description: The workspace this trigger belongs to
service_name:
$ref: "#/components/schemas/NativeServiceName"
script_path:
type: string
description: The path to the script or flow that will be triggered
is_flow:
type: boolean
description: Whether the trigger targets a flow (true) or a script (false)
service_config:
type: object
description: Configuration for the trigger including event_type and service_config
additionalProperties: true
error:
type: string
nullable: true
description: Error message if the trigger is in an error state
external_data:
type: object
description: Configuration data from the external service
additionalProperties: true
required:
- external_id
- workspace_id
- service_name
- script_path
- is_flow
- service_config
- external_data
WorkspaceIntegrations:
type: object
properties:
service_name:
$ref: '#/components/schemas/NativeServiceName'
oauth_data:
nullable: true
$ref: '#/components/schemas/WorkspaceOAuthConfig'
required:
- service_name
WorkspaceOAuthConfig:
type: object
properties:
client_id:
type: string
description: The OAuth client ID for the workspace
client_secret:
type: string
description: The OAuth client secret for the workspace
base_url:
type: string
format: uri
description: The base URL of the workspace
redirect_uri:
type: string
format: uri
description: The OAuth redirect URI
required:
- client_id
- client_secret
- base_url
- redirect_uri
WebhookEvent:
type: object
properties:
type:
type: string
enum: [webhook]
request_type:
$ref: '#/components/schemas/WebhookRequestType'
required:
- type
- request_type
WebhookRequestType:
type: string
description: The type of webhook request (define possible values here)
enum:
- async
- sync
RedirectUri:
type: object
properties:
redirect_uri:
type:
string
required:
- redirect_uri
NativeTriggerData:
type: object
description: Data for creating or updating a native trigger
properties:
script_path:
type: string
description: The path to the script or flow that will be triggered
is_flow:
type: boolean
description: Whether the trigger targets a flow (true) or a script (false)
service_config:
type: object
description: Service-specific configuration (e.g., event types, filters)
additionalProperties: true
required:
- script_path
- is_flow
- service_config
CreateTriggerResponse:
type: object
description: Response returned when a native trigger is created
properties:
external_id:
type: string
description: The external ID of the created trigger from the external service
required:
- external_id
SyncResult:
type: object
properties:
already_in_sync:
type: boolean
added_count:
type: integer
added_triggers:
type: array
items:
type: string
total_external:
type: integer
total_windmill:
type: integer
required:
- already_in_sync
- added_count
- added_triggers
- total_external
- total_windmill
NextCloudEventType:
type: object
properties:
id:
type: string
name:
type: string
description:
type: string
category:
type: string
path:
type: string
required:
- id
- name
- path
+94 -24
View File
@@ -1789,6 +1789,8 @@ pub struct RunJobQuery {
pub skip_preprocessor: Option<bool>,
pub poll_delay_ms: Option<u64>,
pub memory_id: Option<Uuid>,
pub trigger_external_id: Option<String>,
pub service_name: Option<String>,
pub suspended_mode: Option<bool>,
}
@@ -2089,7 +2091,11 @@ async fn list_queue_jobs(
));
}
let args_field = if include_args { "v2_job.args" } else { "null as args" };
let args_field = if include_args {
"v2_job.args"
} else {
"null as args"
};
let sql = list_queue_jobs_query(
&w_id,
@@ -2460,11 +2466,23 @@ async fn list_jobs(
if include_args {
cj_fields = UnifiedJob::completed_job_fields()
.iter()
.map(|f| if *f == "null as args" { "v2_job.args" } else { *f })
.map(|f| {
if *f == "null as args" {
"v2_job.args"
} else {
*f
}
})
.collect();
qj_fields = UnifiedJob::queued_job_fields()
.iter()
.map(|f| if *f == "null as args" { "v2_job.args" } else { *f })
.map(|f| {
if *f == "null as args" {
"v2_job.args"
} else {
*f
}
})
.collect();
cj_fields_ref = &cj_fields;
qj_fields_ref = &qj_fields;
@@ -4245,18 +4263,26 @@ pub async fn run_flow_by_path(
Query(run_query): Query<RunJobQuery>,
args: RawWebhookArgs,
) -> error::Result<(StatusCode, String)> {
let args = args
.to_args_from_runnable(
&authed,
&db,
&w_id,
RunnableId::from_flow_path(flow_path.to_path()),
run_query.skip_preprocessor,
)
.await?;
let (args, trigger_metadata) = get_args_and_trigger_metadata(
&db,
&authed,
RunnableId::from_flow_path(flow_path.to_path()),
&run_query,
&w_id,
args,
)
.await?;
let (uuid, _, _) = push_flow_job_by_path_into_queue(
authed, db, None, user_db, w_id, flow_path, run_query, args, None,
authed,
db,
None,
user_db,
w_id,
flow_path,
run_query,
args,
trigger_metadata,
)
.await?;
@@ -4650,15 +4676,15 @@ pub async fn run_script_by_path(
Query(run_query): Query<RunJobQuery>,
args: RawWebhookArgs,
) -> error::Result<(StatusCode, String)> {
let args = args
.to_args_from_runnable(
&authed,
&db,
&w_id,
RunnableId::from_script_path(script_path.to_path()),
run_query.skip_preprocessor,
)
.await?;
let (args, trigger_metadata) = get_args_and_trigger_metadata(
&db,
&authed,
RunnableId::from_script_path(script_path.to_path()),
&run_query,
&w_id,
args,
)
.await?;
let (uuid, _, _) = push_script_job_by_path_into_queue(
authed,
@@ -4669,13 +4695,53 @@ pub async fn run_script_by_path(
script_path,
run_query,
args,
None,
trigger_metadata,
)
.await?;
Ok((StatusCode::CREATED, uuid.to_string()))
}
#[allow(unused)]
pub async fn get_args_and_trigger_metadata(
db: &DB,
authed: &ApiAuthed,
runnable_id: RunnableId,
run_query: &RunJobQuery,
w_id: &str,
args: RawWebhookArgs,
) -> error::Result<(PushArgsOwned, Option<TriggerMetadata>)> {
use windmill_common::triggers::TriggerMetadata;
// Build trigger metadata if this is a native trigger request
#[cfg(feature = "native_trigger")]
let trigger_metadata = if let Some(service_name_str) = &run_query.service_name {
use crate::native_triggers::ServiceName;
let service_name = ServiceName::try_from(service_name_str.to_owned())?;
Some(TriggerMetadata::new(
run_query.trigger_external_id.clone(),
service_name.as_job_trigger_kind(),
))
} else {
None
};
#[cfg(not(feature = "native_trigger"))]
let trigger_metadata: Option<TriggerMetadata> = None;
let args = args
.to_args_from_runnable(
&authed,
&db,
&w_id,
runnable_id,
run_query.skip_preprocessor,
)
.await?;
Ok((args, trigger_metadata))
}
pub async fn push_script_job_by_path_into_queue<'c>(
authed: ApiAuthed,
db: DB,
@@ -8413,7 +8479,11 @@ async fn list_completed_jobs(
let (per_page, offset) = paginate(pagination);
let args_field = if include_args { "v2_job.args" } else { "null as args" };
let args_field = if include_args {
"v2_job.args"
} else {
"null as args"
};
let sql = list_completed_jobs_query(
&w_id,
+14
View File
@@ -164,6 +164,8 @@ mod smtp_server_oss;
pub mod teams_approvals_ee;
mod teams_approvals_oss;
#[cfg(feature = "native_trigger")]
pub mod native_triggers;
mod public_app_layer;
mod static_assets;
#[cfg(all(feature = "stripe", feature = "enterprise", feature = "private"))]
@@ -494,6 +496,18 @@ pub async fn run_server(
.nest("/job_helpers", job_helpers_service)
.nest("/jobs", jobs::workspaced_service())
.nest("/debug", debug::workspaced_service())
.nest("/native_triggers", {
#[cfg(feature = "native_trigger")]
{
native_triggers::handler::generate_native_trigger_routers().merge(
native_triggers::workspace_integrations::workspaced_service(),
)
}
#[cfg(not(feature = "native_trigger"))]
{
axum::Router::new()
}
})
.nest("/oauth", {
#[cfg(feature = "oauth2")]
{
@@ -0,0 +1,562 @@
use crate::{
db::ApiAuthed,
native_triggers::{
delete_native_trigger, delete_token_by_prefix, get_native_trigger, get_token_by_prefix,
get_workspace_integration, list_native_triggers, store_native_trigger,
update_native_trigger_error, External, NativeTrigger, NativeTriggerConfig,
NativeTriggerData, ServiceName,
},
users::{create_token_internal, NewToken},
utils::check_scopes,
};
use axum::{
extract::{Path, Query},
routing::{delete, get, post},
Extension, Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::PgConnection;
use std::sync::Arc;
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
utils::rd_string,
DB,
};
async fn require_is_writer_on_runnable(
authed: &ApiAuthed,
path: &str,
is_flow: bool,
w_id: &str,
db: DB,
) -> Result<()> {
if is_flow {
crate::flows::require_is_writer(authed, path, w_id, db).await
} else {
crate::scripts::require_is_writer(authed, path, w_id, db).await
}
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
pub page: Option<usize>,
pub per_page: Option<usize>,
}
#[derive(Debug, Serialize)]
pub struct FullTriggerResponse<T: Serialize> {
#[serde(flatten)]
pub windmill_data: NativeTrigger,
pub external_data: T,
}
#[derive(Debug, Serialize)]
pub struct CreateTriggerResponse {
pub external_id: String,
}
async fn new_webhook_token(
tx: &mut PgConnection,
db: &DB,
authed: &ApiAuthed,
script_path: &str,
is_flow: bool,
workspace_id: &str,
service_name: ServiceName,
) -> Result<String> {
let kind = if is_flow { "flows" } else { "scripts" };
let scopes = vec![format!("jobs:run:{kind}:{script_path}")];
let label = format!("webhook-{}-{}", service_name.as_str(), rd_string(5));
let token_config = NewToken::new(
Some(label),
None,
None,
Some(scopes),
Some(workspace_id.to_owned()),
);
let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?;
Ok(token)
}
async fn create_native_trigger<T: External>(
Extension(handler): Extension<Arc<T>>,
Extension(service_name): Extension<ServiceName>,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(workspace_id): Path<String>,
Json(data): Json<NativeTriggerData<T::ServiceConfig>>,
) -> JsonResult<CreateTriggerResponse> {
check_scopes(&authed, || {
format!("native_triggers:write:{}", &data.script_path)
})?;
require_is_writer_on_runnable(
&authed,
&data.script_path,
data.is_flow,
&workspace_id,
db.clone(),
)
.await?;
let mut tx = user_db.begin(&authed).await?;
let webhook_token = new_webhook_token(
&mut *tx,
&db,
&authed,
&data.script_path,
data.is_flow,
&workspace_id,
service_name,
)
.await?;
let integration = get_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
let oauth_data: T::OAuthData = serde_json::from_value(integration.oauth_data).map_err(|e| {
Error::InternalErr(format!(
"Failed to parse {} OAuth data: {}",
T::DISPLAY_NAME,
e
))
})?;
let resp = handler
.create(
&workspace_id,
&oauth_data,
&webhook_token,
&data,
&db,
&mut tx,
)
.await?;
let (external_id, _) = handler.external_id_and_metadata_from_response(&resp);
// update the created external trigger with a new uri containing the external_id
handler
.update(
&workspace_id,
&oauth_data,
&external_id,
&webhook_token,
&data,
&db,
&mut tx,
)
.await?;
// Fetch the updated trigger data from the external service and extract service_config
let trigger_data = handler
.get(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
.await?;
let service_config = handler.extract_service_config_from_trigger_data(&trigger_data)?;
let config = NativeTriggerConfig {
script_path: data.script_path.clone(),
is_flow: data.is_flow,
webhook_token,
};
store_native_trigger(
&mut *tx,
&workspace_id,
service_name,
&external_id,
&config,
service_config,
)
.await?;
audit_log(
&mut *tx,
&authed,
&format!("native_triggers.{}.create", service_name),
ActionKind::Create,
&workspace_id,
Some(&external_id),
None,
)
.await?;
tx.commit().await?;
Ok(Json(CreateTriggerResponse { external_id }))
}
async fn update_native_trigger_handler<T: External>(
Extension(handler): Extension<Arc<T>>,
Extension(service_name): Extension<ServiceName>,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, external_id)): Path<(String, String)>,
Json(data): Json<NativeTriggerData<T::ServiceConfig>>,
) -> Result<String> {
check_scopes(&authed, || {
format!("native_triggers:write:{}", &data.script_path)
})?;
require_is_writer_on_runnable(
&authed,
&data.script_path,
data.is_flow,
&workspace_id,
db.clone(),
)
.await?;
let mut tx = user_db.begin(&authed).await?;
let existing = get_native_trigger(&mut *tx, &workspace_id, service_name, &external_id)
.await?
.ok_or_else(|| Error::NotFound(format!("Native trigger not found: {}", external_id)))?;
// Look up the full token using the stored prefix (use db, not tx, for token table)
let webhook_token = match get_token_by_prefix(&db, &existing.webhook_token_prefix).await? {
Some(token) => token,
None => {
tracing::warn!(
"Webhook token not found for trigger {} (prefix: {}), recreating token",
external_id,
existing.webhook_token_prefix
);
new_webhook_token(
&mut *tx,
&db,
&authed,
&data.script_path,
data.is_flow,
&workspace_id,
service_name,
)
.await?
}
};
let integration = get_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
let oauth_data: T::OAuthData = serde_json::from_value(integration.oauth_data).map_err(|e| {
Error::InternalErr(format!(
"Failed to parse {} OAuth data: {}",
T::DISPLAY_NAME,
e
))
})?;
handler
.update(
&workspace_id,
&oauth_data,
&external_id,
&webhook_token,
&data,
&db,
&mut tx,
)
.await?;
// Fetch the updated trigger data from the external service and extract service_config
let trigger_data = handler
.get(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
.await?;
let service_config = handler.extract_service_config_from_trigger_data(&trigger_data)?;
let config = NativeTriggerConfig {
script_path: data.script_path.clone(),
is_flow: data.is_flow,
webhook_token,
};
store_native_trigger(
&mut *tx,
&workspace_id,
service_name,
&external_id,
&config,
service_config,
)
.await?;
audit_log(
&mut *tx,
&authed,
&format!("native_triggers.{}.update", service_name),
ActionKind::Update,
&workspace_id,
Some(&external_id),
None,
)
.await?;
tx.commit().await?;
Ok(format!("Native trigger updated"))
}
async fn get_native_trigger_handler<T: External>(
Extension(handler): Extension<Arc<T>>,
Extension(service_name): Extension<ServiceName>,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, external_id)): Path<(String, String)>,
) -> JsonResult<FullTriggerResponse<T::TriggerData>> {
let mut tx = user_db.begin(&authed).await?;
let windmill_trigger = get_native_trigger(&mut *tx, &workspace_id, service_name, &external_id)
.await?
.ok_or_else(|| Error::NotFound(format!("Native trigger not found: {}", external_id)))?;
check_scopes(&authed, || {
format!("native_triggers:read:{}", &windmill_trigger.script_path)
})?;
require_is_writer_on_runnable(
&authed,
&windmill_trigger.script_path,
windmill_trigger.is_flow,
&workspace_id,
db.clone(),
)
.await?;
let integration = get_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
let oauth_data: T::OAuthData = serde_json::from_value(integration.oauth_data).map_err(|e| {
Error::InternalErr(format!(
"Failed to parse {} OAuth data: {}",
T::DISPLAY_NAME,
e
))
})?;
let native_trigger = handler
.get(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
.await;
let native_trigger_config = match native_trigger {
Ok(native_cfg) => {
// Clear error if it was set
if windmill_trigger.error.is_some() {
update_native_trigger_error(
&mut *tx,
&workspace_id,
service_name,
&external_id,
None,
)
.await?;
}
native_cfg
}
Err(Error::NotFound(_)) => {
let error_msg = "Trigger no longer exists on external service".to_string();
tracing::warn!(
"Native trigger no longer exists on external service {}, setting error",
service_name
);
update_native_trigger_error(
&mut *tx,
&workspace_id,
service_name,
&external_id,
Some(&error_msg),
)
.await?;
tx.commit().await?;
return Err(Error::NotFound(format!(
"Trigger '{}' no longer exists on external service {}",
external_id, service_name
)));
}
Err(e) => return Err(e),
};
let full_resp = Json(FullTriggerResponse {
windmill_data: windmill_trigger,
external_data: native_trigger_config,
});
Ok(full_resp)
}
async fn delete_native_trigger_handler<T: External>(
Extension(handler): Extension<Arc<T>>,
Extension(service_name): Extension<ServiceName>,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, external_id)): Path<(String, String)>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
let existing = get_native_trigger(&mut *tx, &workspace_id, service_name, &external_id)
.await?
.ok_or_else(|| Error::NotFound(format!("Native trigger not found: {}", external_id)))?;
check_scopes(&authed, || {
format!("native_triggers:write:{}", &existing.script_path)
})?;
require_is_writer_on_runnable(
&authed,
&existing.script_path,
existing.is_flow,
&workspace_id,
db.clone(),
)
.await?;
let integration = get_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
let oauth_data: T::OAuthData = serde_json::from_value(integration.oauth_data).map_err(|e| {
Error::InternalErr(format!(
"Failed to parse {} OAuth data: {}",
T::DISPLAY_NAME,
e
))
})?;
handler
.delete(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
.await?;
let deleted =
delete_native_trigger(&mut *tx, &workspace_id, service_name, &external_id).await?;
if !deleted {
return Err(Error::NotFound(format!("Native trigger not found")));
}
// Delete the webhook token using its prefix
if !delete_token_by_prefix(&db, &existing.webhook_token_prefix).await? {
tracing::warn!(
"Webhook token not found when deleting trigger {} (prefix: {})",
external_id,
existing.webhook_token_prefix
);
}
audit_log(
&mut *tx,
&authed,
&format!("native_triggers.{}.delete", service_name),
ActionKind::Delete,
&workspace_id,
Some(&external_id),
None,
)
.await?;
tx.commit().await?;
Ok(format!("Native trigger deleted"))
}
async fn exists_native_trigger_handler<T: External>(
Extension(service_name): Extension<ServiceName>,
_authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((workspace_id, external_id)): Path<(String, String)>,
) -> JsonResult<bool> {
let exists = sqlx::query_scalar!(
r#"
SELECT EXISTS(
SELECT 1
FROM native_trigger
WHERE
workspace_id = $1 AND
service_name = $2 AND
external_id = $3
)
"#,
workspace_id,
service_name as ServiceName,
external_id
)
.fetch_one(&db)
.await?
.unwrap_or(false);
Ok(Json(exists))
}
async fn list_native_triggers_handler<T: External>(
Extension(service_name): Extension<ServiceName>,
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(workspace_id): Path<String>,
Query(query): Query<ListQuery>,
) -> JsonResult<Vec<NativeTrigger>> {
let mut tx = user_db.begin(&authed).await?;
let triggers = list_native_triggers(
&mut *tx,
&workspace_id,
service_name,
query.page,
query.per_page,
)
.await?;
tx.commit().await?;
Ok(Json(triggers))
}
pub fn service_routes<T: External + 'static>(handler: T) -> Router {
let additional_routes = handler.additional_routes();
let service_name = T::SERVICE_NAME;
let handler_arc = Arc::new(handler);
let standard_routes = Router::new()
.route("/create", post(create_native_trigger::<T>))
.route("/list", get(list_native_triggers_handler::<T>))
.route("/get/:external_id", get(get_native_trigger_handler::<T>))
.route(
"/update/:external_id",
post(update_native_trigger_handler::<T>),
)
.route(
"/delete/:external_id",
delete(delete_native_trigger_handler::<T>),
)
.route(
"/exists/:external_id",
get(exists_native_trigger_handler::<T>),
);
standard_routes
.merge(additional_routes)
.layer(Extension(handler_arc))
.layer(Extension(service_name))
}
/// Generates routes for all registered native trigger services.
/// When adding a new service, add a new `.nest()` call here.
pub fn generate_native_trigger_routers() -> Router {
let router = Router::new();
#[cfg(feature = "native_trigger")]
{
use crate::native_triggers::nextcloud::NextCloud;
// Register all service routes here
// When adding a new service:
// 1. Import the handler: use crate::native_triggers::newservice::NewServiceHandler;
// 2. Add the route: .nest("/newservice", service_routes(NewServiceHandler))
return router.nest("/nextcloud", service_routes(NextCloud));
// Add new services here:
// .nest("/newservice", service_routes(NewServiceHandler))
}
#[cfg(not(feature = "native_trigger"))]
{
router
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,304 @@
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::value::to_raw_value;
use sqlx::PgConnection;
use std::collections::HashMap;
use windmill_common::{
error::{Error, Result},
BASE_URL, DB,
};
use crate::native_triggers::{
generate_webhook_service_url,
nextcloud::{
routes, NextCloud, NextCloudOAuthData, NextCloudTriggerData, NextcloudServiceConfig,
OcsResponse,
},
External, NativeTriggerData, ServiceName,
};
lazy_static::lazy_static! {
pub static ref TOKEN_NEEDED: Box<serde_json::value::RawValue> = to_raw_value(&serde_json::json!({
"user_roles": ["owner", "trigger"]
})).unwrap();
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct FullNextcloudPayload {
pub http_method: String,
pub uri: String,
pub token_needed: Box<serde_json::value::RawValue>,
#[serde(flatten)]
service_config: NextcloudServiceConfig,
}
impl FullNextcloudPayload {
async fn new(
w_id: &str,
external_id: Option<&str>,
webhook_token: &str,
data: &NativeTriggerData<NextcloudServiceConfig>,
) -> FullNextcloudPayload {
let base_url = &*BASE_URL.read().await;
let uri = generate_webhook_service_url(
base_url,
w_id,
&data.script_path,
data.is_flow,
external_id,
ServiceName::Nextcloud,
webhook_token,
);
FullNextcloudPayload {
http_method: http::Method::POST.to_string().to_uppercase(),
uri,
token_needed: TOKEN_NEEDED.clone(),
service_config: data.service_config.clone(),
}
}
}
#[derive(Debug, Deserialize)]
pub struct RegisterWebhookResponse {
pub id: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct User {
pub uid: String,
#[serde(rename = "displayName")]
pub display_name: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WebhookPayload {
pub event: EventPayload,
pub user: User,
pub time: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EventPayload {
pub node: Node,
#[serde(rename = "class")]
pub class_name: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Node {
pub id: i64,
pub path: String,
}
#[async_trait::async_trait]
impl External for NextCloud {
type ServiceConfig = NextcloudServiceConfig;
type TriggerData = NextCloudTriggerData;
type OAuthData = NextCloudOAuthData;
type CreateResponse = RegisterWebhookResponse;
const SERVICE_NAME: ServiceName = ServiceName::Nextcloud;
const DISPLAY_NAME: &'static str = "Nextcloud";
const SUPPORT_WEBHOOK: bool = true;
const TOKEN_ENDPOINT: &'static str = "/apps/oauth2/api/v1/token";
const REFRESH_ENDPOINT: &'static str = "/apps/oauth2/api/v1/token";
async fn create(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
webhook_token: &str,
data: &NativeTriggerData<Self::ServiceConfig>,
db: &DB,
tx: &mut PgConnection,
) -> Result<Self::CreateResponse> {
// During create, we don't have external_id yet (it comes from NextCloud's response)
let full_nextcloud_payload =
FullNextcloudPayload::new(w_id, None, webhook_token, data).await;
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks",
oauth_data.base_url
);
let mut headers = HashMap::new();
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let ocs_response = self
.http_client_request::<OcsResponse<RegisterWebhookResponse>, _>(
&url,
Method::POST,
w_id,
tx,
db,
Some(headers),
Some(&full_nextcloud_payload),
)
.await?;
Ok(ocs_response.ocs.data)
}
async fn update(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
webhook_token: &str,
data: &NativeTriggerData<Self::ServiceConfig>,
db: &DB,
tx: &mut PgConnection,
) -> Result<()> {
// During update, we have the external_id so include it in the webhook URL
let full_nextcloud_payload =
FullNextcloudPayload::new(w_id, Some(external_id), webhook_token, data).await;
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}",
oauth_data.base_url, external_id
);
let mut headers = HashMap::new();
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let _ = self
.http_client_request::<serde_json::Value, _>(
&url,
Method::POST,
w_id,
tx,
db,
Some(headers),
Some(&full_nextcloud_payload),
)
.await?;
Ok(())
}
async fn get(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) -> Result<Self::TriggerData> {
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}",
oauth_data.base_url, external_id
);
let mut headers = HashMap::new();
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let ocs_response: OcsResponse<NextCloudTriggerData> = self
.http_client_request::<_, ()>(&url, Method::GET, w_id, tx, db, Some(headers), None)
.await?;
Ok(ocs_response.ocs.data)
}
async fn delete(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) -> Result<()> {
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}",
oauth_data.base_url, external_id
);
let mut headers = HashMap::new();
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let _: serde_json::Value = self
.http_client_request::<_, ()>(&url, Method::DELETE, w_id, tx, db, Some(headers), None)
.await
.or_else(|e| match &e {
Error::InternalErr(msg) if msg.contains("404") => Ok(serde_json::Value::Null),
_ => Err(e),
})?;
Ok(())
}
async fn exists(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) -> Result<bool> {
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}",
oauth_data.base_url, external_id
);
let mut headers = HashMap::new();
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let _ = self
.http_client_request::<serde_json::Value, ()>(
&url,
Method::GET,
w_id,
tx,
db,
Some(headers),
None,
)
.await?;
Ok(true)
}
async fn list_all(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
db: &DB,
tx: &mut PgConnection,
) -> Result<Vec<Self::TriggerData>> {
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks",
oauth_data.base_url
);
let mut headers = HashMap::new();
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let ocs_response = self
.http_client_request::<OcsResponse<Vec<NextCloudTriggerData>>, ()>(
&url,
Method::GET,
w_id,
tx,
db,
Some(headers),
None,
)
.await?;
Ok(ocs_response.ocs.data)
}
fn external_id_and_metadata_from_response(
&self,
resp: &Self::CreateResponse,
) -> (String, Option<serde_json::Value>) {
(resp.id.to_string(), None)
}
fn get_external_id_from_trigger_data(&self, data: &Self::TriggerData) -> String {
data.id.to_string()
}
fn additional_routes(&self) -> axum::Router {
routes::nextcloud_routes(self.clone())
}
}
@@ -0,0 +1,64 @@
use serde::{Deserialize, Serialize};
pub mod external;
mod routes;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NextCloudOAuthData {
pub base_url: String,
pub access_token: String,
pub refresh_token: Option<String>,
pub token_expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct OcsResponse<T = String> {
pub ocs: OcsData<T>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Meta {
status: String,
#[serde(rename = "statuscode")]
status_code: u16,
message: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct OcsData<T> {
pub meta: Meta,
pub data: T,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NextCloudEventType {
pub name: String,
pub description: Option<String>,
pub path: Option<String>,
pub parameters: Option<serde_json::Value>,
}
#[derive(Copy, Clone)]
pub struct NextCloud;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NextcloudServiceConfig {
pub event: String,
pub event_filter: Option<Box<serde_json::value::RawValue>>,
pub user_id_filter: Option<String>,
pub headers: Option<Box<serde_json::value::RawValue>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NextCloudTriggerData {
#[serde(skip_serializing)]
pub id: i64,
#[serde(skip_serializing)]
pub uri: String,
pub event: String,
pub event_filter: Option<Box<serde_json::value::RawValue>>,
pub user_id_filter: Option<String>,
pub headers: Option<Box<serde_json::value::RawValue>>,
}
@@ -0,0 +1,66 @@
use std::{collections::HashMap, sync::Arc};
use axum::{extract::Path, routing::get, Extension, Json, Router};
use http::Method;
use windmill_common::{
db::UserDB,
error::{Error, JsonResult},
DB,
};
use crate::{
db::ApiAuthed,
native_triggers::{
get_workspace_integration,
nextcloud::{NextCloudEventType, OcsResponse},
External, OAuthConfig, ServiceName,
},
};
async fn list_available_events<T: External>(
authed: ApiAuthed,
Extension(handler): Extension<Arc<T>>,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(workspace_id): Path<String>,
) -> JsonResult<Vec<NextCloudEventType>> {
let mut tx = user_db.clone().begin(&authed).await?;
let integration =
get_workspace_integration(&mut *tx, &workspace_id, ServiceName::Nextcloud).await?;
let auth = serde_json::from_value::<OAuthConfig>(integration.oauth_data)
.map_err(|e| Error::InternalErr(format!("Failed to parse NextCloud OAuth data: {}", e)))?;
let url = format!(
"{}/ocs/v2.php/apps/integration_windmill/api/v1/list/events",
&auth.base_url,
);
let mut headers = HashMap::new();
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let ocs_response = handler
.http_client_request::<OcsResponse, ()>(
&url,
Method::GET,
&workspace_id,
&mut *tx,
&db,
Some(headers),
None,
)
.await?;
tx.commit().await?;
let events = serde_json::from_str(&ocs_response.ocs.data)
.map_err(|e| Error::InternalErr(format!("Failed to parse NextCloud events data: {}", e)))?;
Ok(Json(events))
}
pub fn nextcloud_routes<T: External>(service: T) -> Router {
let service = Arc::new(service);
Router::new()
.route("/events", get(list_available_events::<T>))
.layer(Extension(service))
}
@@ -0,0 +1,416 @@
use std::collections::HashMap;
use windmill_common::error::Result;
use windmill_common::DB;
use serde::Serialize;
use crate::native_triggers::{
decrypt_oauth_data, list_native_triggers, update_native_trigger_error,
update_native_trigger_service_config, External, ServiceName,
};
#[derive(Debug, Serialize)]
pub struct TriggerSyncInfo {
pub external_id: String,
pub script_path: String,
pub action: SyncAction,
}
#[derive(Debug, Serialize)]
pub enum SyncAction {
ErrorSet(String),
ErrorCleared,
ConfigUpdated,
}
#[derive(Debug, Serialize)]
pub struct SyncError {
pub resource_path: String,
pub error_message: String,
pub error_type: String,
}
#[derive(Debug)]
pub struct BackgroundSyncResult {
pub workspaces_processed: usize,
pub total_synced: usize,
pub total_errors: usize,
pub service_results: HashMap<ServiceName, ServiceSyncResult>,
}
#[derive(Debug)]
pub struct ServiceSyncResult {
pub synced_triggers: Vec<TriggerSyncInfo>,
pub errors: Vec<SyncError>,
}
pub async fn sync_all_triggers(db: &DB) -> Result<BackgroundSyncResult> {
tracing::info!("Starting native triggers sync");
let mut service_results: HashMap<ServiceName, ServiceSyncResult> = HashMap::new();
let mut total_synced = 0;
let mut total_errors = 0;
let mut workspaces_processed = 0;
// Sync all registered services
// Each service only syncs workspaces that have the corresponding integration configured
#[cfg(feature = "native_trigger")]
{
use crate::native_triggers::nextcloud::NextCloud;
let (service_name, result) = sync_service_triggers(db, NextCloud).await;
total_synced += result.synced_triggers.len();
total_errors += result.errors.len();
service_results.insert(service_name, result);
// Add new services here:
// use crate::native_triggers::newservice::NewService;
// let (service_name, result) = sync_service_triggers(db, NewService).await;
// total_synced += result.synced_triggers.len();
// total_errors += result.errors.len();
// service_results.insert(service_name, result);
}
// Count unique workspaces processed across all services
for result in service_results.values() {
workspaces_processed += result
.synced_triggers
.iter()
.map(|t| &t.external_id)
.collect::<std::collections::HashSet<_>>()
.len();
}
let result =
BackgroundSyncResult { workspaces_processed, total_synced, total_errors, service_results };
tracing::info!(
"Completed native triggers sync: {} updated, {} errors",
result.total_synced,
result.total_errors
);
Ok(result)
}
async fn sync_service_triggers<T: External>(
db: &DB,
handler: T,
) -> (ServiceName, ServiceSyncResult) {
let mut all_synced_triggers = Vec::new();
let mut all_errors = Vec::new();
// Only sync workspaces that have the corresponding integration configured
let workspaces_with_integration = match sqlx::query_scalar!(
r#"
SELECT wi.workspace_id
FROM workspace_integrations wi
JOIN workspace w ON w.id = wi.workspace_id
WHERE wi.service_name = $1
AND wi.oauth_data IS NOT NULL
AND w.deleted = false
"#,
T::SERVICE_NAME as ServiceName
)
.fetch_all(db)
.await
{
Ok(workspaces) => workspaces,
Err(e) => {
tracing::error!(
"Error querying workspaces with {} integration: {:#}",
T::SERVICE_NAME.as_str(),
e
);
all_errors.push(SyncError {
resource_path: "database".to_string(),
error_message: format!("Failed to query workspaces: {}", e),
error_type: "database_error".to_string(),
});
return (
T::SERVICE_NAME,
ServiceSyncResult { synced_triggers: Vec::new(), errors: all_errors },
);
}
};
if workspaces_with_integration.is_empty() {
tracing::debug!(
"No workspaces with {} integration configured, skipping sync",
T::SERVICE_NAME.as_str()
);
return (
T::SERVICE_NAME,
ServiceSyncResult { synced_triggers: Vec::new(), errors: Vec::new() },
);
}
tracing::info!(
"Found {} workspaces with {} integration configured",
workspaces_with_integration.len(),
T::SERVICE_NAME.as_str()
);
for workspace_id in workspaces_with_integration {
let sync_result = sync_workspace_triggers::<T>(db, &workspace_id, &handler).await;
match sync_result {
Ok((synced_triggers, errors)) => {
all_synced_triggers.extend(synced_triggers);
all_errors.extend(errors);
}
Err(e) => {
tracing::error!(
"Error syncing {} triggers for workspace {}: {:#}",
T::SERVICE_NAME.as_str(),
workspace_id,
e
);
all_errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!("Failed to sync workspace: {}", e),
error_type: "workspace_sync_error".to_string(),
});
}
}
}
(
T::SERVICE_NAME,
ServiceSyncResult { synced_triggers: all_synced_triggers, errors: all_errors },
)
}
#[cfg(feature = "native_trigger")]
pub async fn sync_workspace_triggers<T: External>(
db: &DB,
workspace_id: &str,
handler: &T,
) -> Result<(Vec<TriggerSyncInfo>, Vec<SyncError>)> {
tracing::info!(
"Syncing {} triggers for workspace '{}'",
T::SERVICE_NAME.as_str(),
workspace_id
);
let windmill_triggers =
list_native_triggers(db, workspace_id, T::SERVICE_NAME, None, None).await?;
if windmill_triggers.is_empty() {
tracing::info!(
"No {} triggers found for workspace '{}'",
T::SERVICE_NAME.as_str(),
workspace_id
);
return Ok((Vec::new(), Vec::new()));
}
let mut all_synced_triggers = Vec::new();
let mut all_sync_errors = Vec::new();
let oauth_data = {
match decrypt_oauth_data(db, db, workspace_id, T::SERVICE_NAME).await {
Ok(oauth_data) => oauth_data,
Err(e) => {
tracing::error!(
"Failed to get workspace integration OAuth data for {}: {}",
workspace_id,
e
);
all_sync_errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!("Failed to get workspace integration OAuth data: {}", e),
error_type: "oauth_error".to_string(),
});
return Ok((Vec::new(), all_sync_errors));
}
}
};
let mut tx = db.begin().await?;
let external_triggers = match handler
.list_all(workspace_id, &oauth_data, db, &mut tx)
.await
{
Ok(triggers) => triggers,
Err(e) => {
tracing::error!(
"Failed to fetch external triggers for {}: {}",
workspace_id,
e
);
all_sync_errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!("Failed to fetch external triggers: {}", e),
error_type: "external_service_error".to_string(),
});
return Ok((Vec::new(), all_sync_errors));
}
};
tx.commit().await?;
// Build a map of external trigger IDs to their data
let mut external_trigger_map: HashMap<String, &T::TriggerData> = HashMap::new();
for external_trigger in &external_triggers {
let external_id = handler.get_external_id_from_trigger_data(external_trigger);
external_trigger_map.insert(external_id, external_trigger);
}
for trigger in &windmill_triggers {
if !external_trigger_map.contains_key(&trigger.external_id) {
// Trigger no longer exists on external service - set error
let error_msg = "Trigger no longer exists on external service".to_string();
if trigger.error.as_deref() != Some(&error_msg) {
tracing::info!(
"Trigger (external_id: '{}', script_path: '{}') no longer exists in external service, setting error",
trigger.external_id,
trigger.script_path
);
match update_native_trigger_error(
db,
workspace_id,
T::SERVICE_NAME,
&trigger.external_id,
Some(&error_msg),
)
.await
{
Ok(()) => {
all_synced_triggers.push(TriggerSyncInfo {
external_id: trigger.external_id.clone(),
script_path: trigger.script_path.clone(),
action: SyncAction::ErrorSet(error_msg),
});
}
Err(e) => {
tracing::error!(
"Failed to update error for trigger (external_id: '{}'): {}",
trigger.external_id,
e
);
all_sync_errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!(
"Failed to update error for trigger (external_id: '{}'): {}",
trigger.external_id, e
),
error_type: "database_update_error".to_string(),
});
}
}
}
} else {
// Trigger exists on external service
let external_trigger_data = external_trigger_map.get(&trigger.external_id).unwrap();
// Clear error if it was set
if trigger.error.is_some() {
tracing::info!(
"Trigger (external_id: '{}', script_path: '{}') exists on external service, clearing error",
trigger.external_id,
trigger.script_path
);
match update_native_trigger_error(
db,
workspace_id,
T::SERVICE_NAME,
&trigger.external_id,
None,
)
.await
{
Ok(()) => {
all_synced_triggers.push(TriggerSyncInfo {
external_id: trigger.external_id.clone(),
script_path: trigger.script_path.clone(),
action: SyncAction::ErrorCleared,
});
}
Err(e) => {
tracing::error!(
"Failed to clear error for trigger (external_id: '{}'): {}",
trigger.external_id,
e
);
all_sync_errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!(
"Failed to clear error for trigger (external_id: '{}'): {}",
trigger.external_id, e
),
error_type: "database_update_error".to_string(),
});
}
}
}
// Compare service_config and update if different
let external_service_config =
handler.extract_service_config_from_trigger_data(external_trigger_data)?;
let stored_service_config = trigger
.service_config
.clone()
.unwrap_or(serde_json::Value::Null);
if external_service_config != stored_service_config {
tracing::info!(
"Trigger (external_id: '{}', script_path: '{}') config differs from external service, updating local config",
trigger.external_id,
trigger.script_path
);
match update_native_trigger_service_config(
db,
workspace_id,
T::SERVICE_NAME,
&trigger.external_id,
&external_service_config,
)
.await
{
Ok(()) => {
all_synced_triggers.push(TriggerSyncInfo {
external_id: trigger.external_id.clone(),
script_path: trigger.script_path.clone(),
action: SyncAction::ConfigUpdated,
});
}
Err(e) => {
tracing::error!(
"Failed to update config for trigger (external_id: '{}'): {}",
trigger.external_id,
e
);
all_sync_errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!(
"Failed to update config for trigger (external_id: '{}'): {}",
trigger.external_id, e
),
error_type: "database_update_error".to_string(),
});
}
}
} else {
tracing::info!(
"Trigger (external_id: '{}', script_path: '{}') config is the same as external service, no update needed",
trigger.external_id,
trigger.script_path
);
}
}
}
tracing::info!(
"Sync completed for {} in workspace '{}'. Updated: {}, Errors: {}",
T::SERVICE_NAME.as_str(),
workspace_id,
all_synced_triggers.len(),
all_sync_errors.len()
);
Ok((all_synced_triggers, all_sync_errors))
}
@@ -0,0 +1,522 @@
use axum::{
extract::Path,
routing::{delete, get, post},
Extension, Json, Router,
};
#[cfg(feature = "native_trigger")]
use serde_json::to_value;
use sqlx::prelude::FromRow;
use strum::IntoEnumIterator;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
#[cfg(feature = "native_trigger")]
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
utils::require_admin,
variables::{build_crypt, encrypt},
DB,
};
#[cfg(feature = "native_trigger")]
use crate::{
db::ApiAuthed,
native_triggers::{delete_workspace_integration, store_workspace_integration, ServiceName},
};
#[cfg(feature = "native_trigger")]
use windmill_oauth::{OClient, Url, OAUTH_HTTP_CLIENT};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
const STATE_EXPIRATION_SECONDS: i64 = 600; // 10 minutes
/// Generate a signed OAuth state that is cluster-safe.
/// The state contains: workspace_id, service_name, timestamp, and nonce.
/// It's signed with HMAC-SHA256 using the workspace key.
#[cfg(feature = "native_trigger")]
async fn generate_signed_state(
db: &DB,
workspace_id: &str,
service_name: ServiceName,
) -> Result<String> {
use windmill_common::variables::get_workspace_key;
let nonce = uuid::Uuid::new_v4().to_string();
let timestamp = chrono::Utc::now().timestamp();
let payload = format!(
"{}:{}:{}:{}",
workspace_id,
service_name.as_str(),
timestamp,
nonce
);
// Get workspace key for signing
let key = get_workspace_key(workspace_id, db).await?;
let mut mac =
HmacSha256::new_from_slice(key.as_bytes()).map_err(|e| Error::InternalErr(e.to_string()))?;
mac.update(payload.as_bytes());
let signature = mac.finalize().into_bytes();
// Encode as: base64(payload):base64(signature)
let encoded_payload = URL_SAFE_NO_PAD.encode(payload.as_bytes());
let encoded_signature = URL_SAFE_NO_PAD.encode(signature);
Ok(format!("{}:{}", encoded_payload, encoded_signature))
}
/// Validate a signed OAuth state.
/// Returns true if the state is valid (correct signature and not expired).
#[cfg(feature = "native_trigger")]
async fn validate_signed_state(db: &DB, state: &str, workspace_id: &str) -> Result<bool> {
use windmill_common::variables::get_workspace_key;
let parts: Vec<&str> = state.split(':').collect();
if parts.len() != 2 {
return Ok(false);
}
let encoded_payload = parts[0];
let encoded_signature = parts[1];
// Decode payload
let payload_bytes = match URL_SAFE_NO_PAD.decode(encoded_payload) {
Ok(bytes) => bytes,
Err(_) => return Ok(false),
};
let payload = match String::from_utf8(payload_bytes) {
Ok(s) => s,
Err(_) => return Ok(false),
};
// Parse payload: workspace_id:service_name:timestamp:nonce
let payload_parts: Vec<&str> = payload.split(':').collect();
if payload_parts.len() != 4 {
return Ok(false);
}
let state_workspace_id = payload_parts[0];
let timestamp: i64 = match payload_parts[2].parse() {
Ok(ts) => ts,
Err(_) => return Ok(false),
};
// Verify workspace_id matches
if state_workspace_id != workspace_id {
return Ok(false);
}
// Check expiration
let now = chrono::Utc::now().timestamp();
if now - timestamp > STATE_EXPIRATION_SECONDS {
return Ok(false);
}
// Verify signature
let key = get_workspace_key(workspace_id, db).await?;
let mut mac =
HmacSha256::new_from_slice(key.as_bytes()).map_err(|e| Error::InternalErr(e.to_string()))?;
mac.update(payload.as_bytes());
let received_signature = match URL_SAFE_NO_PAD.decode(encoded_signature) {
Ok(bytes) => bytes,
Err(_) => return Ok(false),
};
Ok(mac.verify_slice(&received_signature).is_ok())
}
#[derive(Debug, Serialize)]
pub struct IntegrationStatusResponse {
pub connected: bool,
pub service_name: ServiceName,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
pub created_by: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ListIntegrationsResponse {
pub integrations: Vec<IntegrationStatusResponse>,
}
#[derive(Debug, Serialize)]
pub struct ConnectIntegrationResponse {
pub auth_url: String,
}
#[derive(FromRow, Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceOAuthConfig {
pub client_id: String,
pub client_secret: String,
pub base_url: String,
pub access_token: Option<String>
}
#[derive(Debug, Serialize)]
pub struct OAuthConfigResponse {
pub configured: bool,
pub base_url: Option<String>,
pub redirect_uri: Option<String>,
}
#[cfg(feature = "native_trigger")]
async fn generate_connect_url(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
Json(RedirectUri { redirect_uri }): Json<RedirectUri>,
) -> JsonResult<String> {
require_admin(authed.is_admin, &workspace_id)?;
let oauth_config =
get_workspace_oauth_config_as_oauth_config(&db, &workspace_id, service_name).await?;
// Generate a signed state that is cluster-safe
let state = generate_signed_state(&db, &workspace_id, service_name).await?;
let auth_url = build_authorization_url(&oauth_config, &state, &redirect_uri);
Ok(Json(auth_url))
}
#[cfg(feature = "native_trigger")]
async fn delete_integration(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
) -> JsonResult<String> {
require_admin(authed.is_admin, &workspace_id)?;
let mut tx = user_db.begin(&authed).await?;
let deleted = delete_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
if !deleted {
return Err(Error::NotFound(format!(
"{} integration not found for workspace",
service_name
)));
}
audit_log(
&mut *tx,
&authed,
&format!("workspace_integrations.{}.disconnect", service_name),
ActionKind::Delete,
&workspace_id,
Some(&format!("Disconnected {} integration", service_name)),
None,
)
.await?;
tx.commit().await?;
Ok(Json(format!(
"{} integration disconnected successfully",
service_name
)))
}
#[derive(FromRow, Debug, Deserialize, Serialize)]
struct WorkspaceIntegrations {
service_name: ServiceName,
oauth_data: Option<sqlx::types::Json<WorkspaceOAuthConfig>>,
}
#[cfg(feature = "native_trigger")]
async fn list_integrations(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(_user_db): Extension<UserDB>,
Path(workspace_id): Path<String>,
) -> JsonResult<Vec<WorkspaceIntegrations>> {
require_admin(authed.is_admin, &workspace_id)?;
let mut tx = db.begin().await?;
let integrations = sqlx::query_as!(
WorkspaceIntegrations,
r#"
SELECT
oauth_data as "oauth_data!: sqlx::types::Json<WorkspaceOAuthConfig>",
service_name as "service_name!: ServiceName"
FROM
workspace_integrations
WHERE
workspace_id = $1
"#,
workspace_id
)
.fetch_all(&mut *tx)
.await?;
let key_value = integrations
.into_iter()
.map(|integration| (integration.service_name, integration.oauth_data))
.collect::<std::collections::HashMap<_, _>>();
let integrations = ServiceName::iter()
.map(|service_name| WorkspaceIntegrations {
service_name: service_name,
oauth_data: key_value.get(&service_name).cloned().flatten(),
})
.collect::<Vec<_>>();
tx.commit().await?;
Ok(Json(integrations))
}
async fn integration_exist(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
) -> JsonResult<bool> {
let mut tx = user_db.begin(&authed).await?;
let exists = sqlx::query_scalar!(
r#"
SELECT EXISTS (
SELECT 1
FROM workspace_integrations
WHERE workspace_id = $1
AND service_name = $2
AND oauth_data IS NOT NULL
)
"#,
workspace_id,
service_name as ServiceName
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
Ok(Json(exists))
}
#[derive(Debug, Deserialize)]
struct RedirectUri {
redirect_uri: String,
}
#[cfg(feature = "native_trigger")]
async fn oauth_callback(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, service_name, code, state)): Path<(String, ServiceName, String, String)>,
Json(RedirectUri { redirect_uri }): Json<RedirectUri>,
) -> JsonResult<String> {
require_admin(authed.is_admin, &workspace_id)?;
// Validate the signed state (cluster-safe, no DB storage needed)
let state_was_valid = validate_signed_state(&db, &state, &workspace_id).await?;
if !state_was_valid {
return Err(Error::BadRequest(
"Invalid or expired state parameter".to_string(),
));
}
let oauth_config =
get_workspace_oauth_config::<WorkspaceOAuthConfig>(&db, &workspace_id, service_name)
.await?;
let token_response =
exchange_code_for_token(&oauth_config, service_name, &code, &redirect_uri).await?;
let mut tx = user_db.begin(&authed).await?;
let mc = build_crypt(&db, &workspace_id).await?;
let mut oauth_data = serde_json::to_value(oauth_config).unwrap();
let encrypted_access_token = encrypt(&mc, &token_response.access_token);
oauth_data["access_token"] = serde_json::Value::String(encrypted_access_token);
if let Some(refresh_token) = token_response.refresh_token {
let encrypted_refresh_token = encrypt(&mc, &refresh_token);
oauth_data["refresh_token"] = serde_json::Value::String(encrypted_refresh_token);
}
if let Some(expires_in) = token_response.expires_in {
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in as i64);
oauth_data["token_expires_at"] = serde_json::Value::String(expires_at.to_rfc3339());
}
store_workspace_integration(&mut *tx, &authed, &workspace_id, service_name, oauth_data).await?;
audit_log(
&mut *tx,
&authed,
&format!("workspace_integrations.{}.connect", service_name),
ActionKind::Create,
&workspace_id,
Some(&format!("Connected {} integration via OAuth", service_name)),
None,
)
.await?;
tx.commit().await?;
Ok(Json(format!(
"{} integration connected successfully via OAuth",
service_name
)))
}
/// Token response from OAuth token exchange
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: Option<String>,
#[serde(default)]
expires_in: Option<u64>,
}
/// Build an OAuth client for native trigger services using windmill-oauth.
#[cfg(feature = "native_trigger")]
fn build_native_oauth_client(
config: &WorkspaceOAuthConfig,
service_name: ServiceName,
redirect_uri: &str,
) -> Result<OClient> {
let auth_url = Url::parse(&format!("{}{}", config.base_url, service_name.auth_endpoint()))
.map_err(|e| Error::InternalErr(format!("Invalid auth URL: {}", e)))?;
let token_url = Url::parse(&format!("{}{}", config.base_url, service_name.token_endpoint()))
.map_err(|e| Error::InternalErr(format!("Invalid token URL: {}", e)))?;
let redirect = Url::parse(redirect_uri).map_err(|e| {
Error::BadRequest(format!(
"Invalid redirect URI '{}': {}. The redirect URI must be an absolute URL (e.g., https://example.com/callback)",
redirect_uri, e
))
})?;
let mut client = OClient::new(config.client_id.clone(), auth_url, token_url);
client.set_client_secret(config.client_secret.clone());
client.set_redirect_url(redirect);
Ok(client)
}
/// Exchange authorization code for tokens using windmill-oauth.
#[cfg(feature = "native_trigger")]
async fn exchange_code_for_token(
config: &WorkspaceOAuthConfig,
service_name: ServiceName,
code: &str,
redirect_uri: &str,
) -> Result<TokenResponse> {
let client = build_native_oauth_client(config, service_name, redirect_uri)?;
let token_response: TokenResponse = client
.exchange_code(code.to_string())
.with_client(&*OAUTH_HTTP_CLIENT)
.execute()
.await
.map_err(|e| Error::InternalErr(format!("Failed to exchange code for token: {:?}", e)))?;
Ok(token_response)
}
async fn get_workspace_oauth_config<T: DeserializeOwned>(
db: &DB,
workspace_id: &str,
service_name: ServiceName,
) -> Result<T> {
let oauth_configs = sqlx::query_scalar!(
r#"
SELECT
oauth_data
FROM
workspace_integrations
WHERE
workspace_id = $1 AND
service_name = $2
"#,
workspace_id,
service_name as ServiceName
)
.fetch_optional(db)
.await?
.ok_or(Error::NotFound(format!(
"Integration for service {} not found",
service_name.as_str()
)))?;
let config = serde_json::from_value::<T>(oauth_configs)
.map_err(|e| Error::InternalErr(format!("Failed to parse OAuth config: {}", e)))?;
Ok(config)
}
#[cfg(feature = "native_trigger")]
pub async fn create_workspace_integration(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
Json(oauth_data): Json<WorkspaceOAuthConfig>,
) -> Result<()> {
require_admin(authed.is_admin, &workspace_id)?;
let mut tx = user_db.begin(&authed).await?;
store_workspace_integration(
&mut tx,
&authed,
&workspace_id,
service_name,
to_value(oauth_data).unwrap(),
)
.await?;
tx.commit().await?;
Ok(())
}
#[inline]
async fn get_workspace_oauth_config_as_oauth_config(
db: &DB,
workspace_id: &str,
service_name: ServiceName,
) -> Result<WorkspaceOAuthConfig> {
get_workspace_oauth_config::<WorkspaceOAuthConfig>(db, workspace_id, service_name).await
}
fn build_authorization_url(
config: &WorkspaceOAuthConfig,
state: &str,
redirect_uri: &str,
) -> String {
let params = [
("response_type", "code"),
("client_id", &config.client_id),
("redirect_uri", redirect_uri),
("state", state),
("scope", "read write"),
];
let query_string = params
.iter()
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
format!("{}/apps/oauth2/authorize?{}", config.base_url, query_string)
}
pub fn workspaced_service() -> Router {
let router = Router::new()
.route("/list", get(list_integrations))
.route("/:service_name/exists", get(integration_exist))
.route("/:service_name/create", post(create_workspace_integration))
.route(
"/:service_name/generate_connect_url",
post(generate_connect_url),
)
.route("/:service_name/delete", delete(delete_integration))
.route("/:service_name/callback/:code/:state", post(oauth_callback));
Router::new().nest("/integrations", router)
}
+5
View File
@@ -257,6 +257,9 @@ pub enum ScopeDomain {
PostgresTriggers,
EmailTriggers,
// Native trigger domains
NativeTriggers,
// System domains
Audit,
Settings,
@@ -310,6 +313,7 @@ impl ScopeDomain {
Self::GcpTriggers => "gcp_triggers",
Self::PostgresTriggers => "postgres_triggers",
Self::EmailTriggers => "email_triggers",
Self::NativeTriggers => "native_triggers",
Self::Audit => "audit",
Self::Settings => "settings",
Self::Workers => "workers",
@@ -366,6 +370,7 @@ impl ScopeDomain {
"ai" => Some(Self::AI),
"indexer" | "srch" => Some(Self::Indexer),
"teams" => Some(Self::Teams),
"native_triggers" => Some(Self::NativeTriggers),
"git_sync" | "github_app" => Some(Self::GitSync),
"capture" => Some(Self::Capture),
"drafts" => Some(Self::Drafts),
+6
View File
@@ -124,6 +124,12 @@ fn build_standard_scope_domains() -> Vec<ScopeDomain> {
"Git synchronization management",
false,
),
(
"native_triggers",
"Native Triggers",
"Native triggers management",
true,
),
];
STANDARD_DOMAINS
@@ -811,6 +811,7 @@ pub struct TriggersCount {
mqtt_count: i64,
sqs_count: i64,
gcp_count: i64,
nextcloud_count: i64,
}
pub async fn get_triggers_count_internal(
@@ -970,6 +971,16 @@ pub async fn get_triggers_count_internal(
.await?
.unwrap_or(0);
let nextcloud_count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM native_trigger WHERE workspace_id = $1 AND script_path = $2 AND is_flow = $3 AND service_name = 'nextcloud'",
w_id,
path,
is_flow,
)
.fetch_one(db)
.await?
.unwrap_or(0);
Ok(Json(TriggersCount {
primary_schedule: primary_schedule.map(|s| TriggerPrimarySchedule { schedule: s }),
schedule_count,
@@ -984,5 +995,6 @@ pub async fn get_triggers_count_internal(
mqtt_count,
gcp_count,
sqs_count,
nextcloud_count,
}))
}
+39 -13
View File
@@ -9,7 +9,7 @@
#![allow(non_snake_case)]
use quick_cache::sync::Cache;
use sqlx::{Postgres, Transaction};
use sqlx::{PgConnection, Postgres, Transaction};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
@@ -389,6 +389,19 @@ pub struct NewToken {
pub workspace_id: Option<String>,
}
#[cfg(feature = "native_trigger")]
impl NewToken {
pub fn new(
label: Option<String>,
expiration: Option<chrono::DateTime<chrono::Utc>>,
impersonate_email: Option<String>,
scopes: Option<Vec<String>>,
workspace_id: Option<String>,
) -> NewToken {
NewToken { label, expiration, impersonate_email, scopes, workspace_id }
}
}
#[derive(Deserialize)]
pub struct Login {
pub email: String,
@@ -2122,13 +2135,13 @@ pub async fn create_session_token<'c>(
Ok(token)
}
async fn create_token(
Extension(db): Extension<DB>,
authed: ApiAuthed,
Json(new_token): Json<NewToken>,
) -> Result<(StatusCode, String)> {
pub async fn create_token_internal(
tx: &mut PgConnection,
db: &DB,
authed: &ApiAuthed,
token_config: NewToken,
) -> Result<String> {
let token = rd_string(32);
let mut tx = db.begin().await?;
let is_super_admin = sqlx::query_scalar!(
"SELECT super_admin FROM password WHERE email = $1",
@@ -2140,7 +2153,7 @@ async fn create_token(
if *CLOUD_HOSTED {
let nb_tokens =
sqlx::query_scalar!("SELECT COUNT(*) FROM token WHERE email = $1", &authed.email)
.fetch_one(&db)
.fetch_one(db)
.await?;
if nb_tokens.unwrap_or(0) >= 10000 {
return Err(Error::BadRequest(
@@ -2155,18 +2168,18 @@ async fn create_token(
VALUES ($1, $2, $3, $4, $5, $6, $7)",
token,
authed.email,
new_token.label,
new_token.expiration,
token_config.label,
token_config.expiration,
is_super_admin,
new_token.scopes.as_ref().map(|x| x.as_slice()),
new_token.workspace_id,
token_config.scopes.as_ref().map(|x| x.as_slice()),
token_config.workspace_id,
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
authed,
"users.token.create",
ActionKind::Create,
&"global",
@@ -2175,6 +2188,19 @@ async fn create_token(
)
.instrument(tracing::info_span!("token", email = &authed.email))
.await?;
Ok(token)
}
async fn create_token(
Extension(db): Extension<DB>,
authed: ApiAuthed,
Json(token_config): Json<NewToken>,
) -> Result<(StatusCode, String)> {
let mut tx = db.begin().await?;
let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?;
tx.commit().await?;
Ok((StatusCode::CREATED, token))
}
+5 -3
View File
@@ -2568,6 +2568,7 @@ struct UsedTriggers {
pub sqs_used: bool,
pub gcp_used: bool,
pub email_used: bool,
pub nextcloud_used: bool,
}
async fn get_used_triggers(
@@ -2576,7 +2577,7 @@ async fn get_used_triggers(
Path(w_id): Path<String>,
) -> JsonResult<UsedTriggers> {
let mut tx = user_db.begin(&authed).await?;
let websocket_used = sqlx::query_as!(
let triggers_used = sqlx::query_as!(
UsedTriggers,
r#"
SELECT
@@ -2588,7 +2589,8 @@ async fn get_used_triggers(
EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS "mqtt_used!",
EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS "sqs_used!",
EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS "gcp_used!",
EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS "email_used!"
EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS "email_used!",
EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS "nextcloud_used!"
"#,
w_id
)
@@ -2596,7 +2598,7 @@ async fn get_used_triggers(
.await?;
tx.commit().await?;
Ok(Json(websocket_used))
Ok(Json(triggers_used))
}
async fn get_workspace_as_superadmin(
@@ -802,6 +802,38 @@ pub(crate) async fn tarball_workspace(
.await?;
}
}
#[cfg(feature = "native_trigger")]
{
use crate::native_triggers::{list_native_triggers, ServiceName};
use strum::IntoEnumIterator;
for service_name in ServiceName::iter() {
let native_triggers =
list_native_triggers(&mut *tx, &w_id, service_name, None, None).await?;
for trigger in native_triggers {
let trigger_str = &to_string_without_metadata(
&trigger,
false,
Some(vec!["webhook_token_prefix"]),
)
.unwrap();
archive
.write_to_archive(
&trigger_str,
&format!(
"{}.{}.{}.{}_native_trigger.json",
trigger.script_path,
if trigger.is_flow { "flow" } else { "script" },
trigger.external_id,
service_name.as_str()
),
)
.await?;
}
}
}
}
if include_users.unwrap_or(false) {
+2
View File
@@ -55,6 +55,7 @@ pub enum JobTriggerKind {
Postgres,
Schedule,
Gcp,
Nextcloud,
}
impl std::fmt::Display for JobTriggerKind {
@@ -71,6 +72,7 @@ impl std::fmt::Display for JobTriggerKind {
JobTriggerKind::Postgres => "postgres",
JobTriggerKind::Schedule => "schedule",
JobTriggerKind::Gcp => "gcp",
JobTriggerKind::Nextcloud => "nextcloud",
};
write!(f, "{}", kind)
}
+3
View File
@@ -21,6 +21,7 @@ pub enum TriggerKind {
Sqs,
Postgres,
Gcp,
Nextcloud,
}
impl TriggerKind {
@@ -37,6 +38,7 @@ impl TriggerKind {
TriggerKind::Sqs => "sqs".to_string(),
TriggerKind::Postgres => "postgres".to_string(),
TriggerKind::Gcp => "gcp".to_string(),
TriggerKind::Nextcloud => "nextcloud".to_string(),
}
}
}
@@ -55,6 +57,7 @@ impl fmt::Display for TriggerKind {
TriggerKind::Sqs => "sqs",
TriggerKind::Postgres => "postgres",
TriggerKind::Gcp => "gcp",
TriggerKind::Nextcloud => "nextcloud",
};
write!(f, "{}", s)
}
+2 -1
View File
@@ -925,7 +925,8 @@ impl<F: Future> Future for WarnAfterFuture<F> {
}
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Hash)]
#[derive(sqlx::Type, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)]
#[sqlx(type_name = "runnable_kind", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum RunnableKind {
Script,
+2 -2
View File
@@ -93,9 +93,9 @@ pub async fn build_crypt(db: &DB, w_id: &str) -> crate::error::Result<MagicCrypt
} else {
let key = get_workspace_key(w_id, db).await?;
tracing::info!(
"crypt for workspace {} with key {} expired, refetching",
"crypt for workspace {} with key {}*** expired, refetching",
w_id,
key
&key[..key.len().min(8)]
);
let crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() {
format!("{}{}", key, salt)
+20 -3
View File
@@ -22,6 +22,7 @@ import {
pushObj,
showConflict,
showDiff,
extractNativeTriggerInfo,
} from "../../types.ts";
import { downloadZip } from "./pull.ts";
@@ -67,7 +68,7 @@ import {
readLockfile,
workspaceDependenciesPathToLanguageAndFilename,
} from "../../utils/metadata.ts";
import { OpenFlow } from "../../../gen/types.gen.ts";
import { OpenFlow, NativeServiceName } from "../../../gen/types.gen.ts";
import { pushResource } from "../resource/resource.ts";
import {
newPathAssigner,
@@ -1349,7 +1350,8 @@ export async function elementsToMap(
path.endsWith(".mqtt_trigger" + ext) ||
path.endsWith(".sqs_trigger" + ext) ||
path.endsWith(".gcp_trigger" + ext) ||
path.endsWith(".email_trigger" + ext))
path.endsWith(".email_trigger" + ext) ||
path.endsWith("_native_trigger" + ext))
) {
continue;
}
@@ -1694,7 +1696,8 @@ function getOrderFromPath(p: string) {
typ == "mqtt_trigger" ||
typ == "sqs_trigger" ||
typ == "gcp_trigger" ||
typ == "email_trigger"
typ == "email_trigger" ||
typ == "native_trigger"
) {
return 14;
} else {
@@ -2995,6 +2998,20 @@ export async function push(
path: removeSuffix(target, ".email_trigger.json"),
});
break;
case "native_trigger": {
const triggerInfo = extractNativeTriggerInfo(change.path);
if (!triggerInfo) {
throw new Error(
`Invalid native trigger path: ${change.path}`
);
}
await wmill.deleteNativeTrigger({
workspace: workspaceId,
serviceName: triggerInfo.serviceName as NativeServiceName,
externalId: triggerInfo.externalId,
});
break;
}
case "variable":
await wmill.deleteVariable({
workspace: workspaceId,
+108
View File
@@ -9,6 +9,9 @@ import {
SqsTrigger,
WebsocketTrigger,
EmailTrigger,
NativeTrigger,
NativeTriggerData,
NativeServiceName,
} from "../../../gen/types.gen.ts";
import { colors, Command, log, SEP, Table } from "../../../deps.ts";
import {
@@ -17,6 +20,7 @@ import {
parseFromFile,
removeType,
TRIGGER_TYPES,
extractNativeTriggerInfo,
} from "../../types.ts";
import {
fromBranchSpecificPath,
@@ -181,6 +185,110 @@ export async function pushTrigger<K extends TriggerType>(
}
}
type NativeTriggerFile = Omit<
NativeTrigger,
"external_id" | "workspace_id" | "error"
>;
export async function pushNativeTrigger(
workspace: string,
filePath: string,
_remoteTrigger: NativeTrigger | undefined,
localTrigger: NativeTriggerFile
): Promise<void> {
const triggerInfo = extractNativeTriggerInfo(filePath);
if (!triggerInfo) {
throw new Error(
`Invalid native trigger file path: ${filePath}. Expected format: {script_path}.{flow|script}.{external_id}.{service}_native_trigger.json`
);
}
const { externalId, serviceName } = triggerInfo;
log.debug(
`Processing local native trigger: service=${serviceName}, external_id=${externalId}`
);
let remoteTrigger: NativeTrigger | undefined;
try {
const result = await wmill.getNativeTrigger({
workspace,
serviceName: serviceName as NativeServiceName,
externalId,
});
// getNativeTrigger returns NativeTriggerWithExternal, extract NativeTrigger fields
remoteTrigger = {
external_id: result.external_id,
workspace_id: result.workspace_id,
service_name: result.service_name,
script_path: result.script_path,
is_flow: result.is_flow,
service_config: result.service_config,
error: result.error,
};
log.debug(`Native trigger ${serviceName}/${externalId} exists on remote`);
} catch {
log.debug(
`Native trigger ${serviceName}/${externalId} does not exist on remote`
);
}
const triggerData: NativeTriggerData = {
script_path: localTrigger.script_path,
is_flow: localTrigger.is_flow,
service_config: localTrigger.service_config,
};
if (remoteTrigger) {
// Compare relevant fields
const localCompare = {
script_path: localTrigger.script_path,
is_flow: localTrigger.is_flow,
service_config: localTrigger.service_config,
};
const remoteCompare = {
script_path: remoteTrigger.script_path,
is_flow: remoteTrigger.is_flow,
service_config: remoteTrigger.service_config,
};
if (isSuperset(localCompare, remoteCompare)) {
log.debug(`Native trigger ${serviceName}/${externalId} is up to date`);
return;
}
log.debug(
`Native trigger ${serviceName}/${externalId} is not up-to-date, updating...`
);
try {
await wmill.updateNativeTrigger({
workspace,
serviceName: serviceName as NativeServiceName,
externalId,
requestBody: triggerData,
});
} catch (e) {
console.error((e as any).body);
throw e;
}
} else {
console.log(
colors.bold.yellow(
`Creating new native trigger: ${serviceName}/${externalId}`
)
);
try {
await wmill.createNativeTrigger({
workspace,
serviceName: serviceName as NativeServiceName,
requestBody: triggerData,
});
} catch (e) {
console.error((e as any).body);
throw e;
}
}
}
async function list(opts: GlobalOptions) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
+40 -1
View File
@@ -23,7 +23,7 @@ import { pushWorkspaceUser } from "./commands/user/user.ts";
import { pushGroup } from "./commands/user/user.ts";
import { pushWorkspaceDependencies } from "./commands/dependencies/dependencies.ts";
import { pushWorkspaceSettings, pushWorkspaceKey } from "./core/settings.ts";
import { pushTrigger } from "./commands/trigger/trigger.ts";
import { pushTrigger, pushNativeTrigger } from "./commands/trigger/trigger.ts";
import { pushRawApp } from "./commands/app/raw_apps.ts";
import {
isFlowPath,
@@ -66,6 +66,9 @@ export const TRIGGER_TYPES = [
"email",
] as const;
export const NATIVE_TRIGGER_SERVICES = ["nextcloud"] as const;
export type NativeTriggerService = (typeof NATIVE_TRIGGER_SERVICES)[number];
export type GlobalOptions = {
baseUrl: string | undefined;
workspace: string | undefined;
@@ -195,6 +198,8 @@ export async function pushObj(
await pushTrigger("gcp", workspace, p, befObj, newObj);
} else if (typeEnding === "email_trigger") {
await pushTrigger("email", workspace, p, befObj, newObj);
} else if (typeEnding === "native_trigger") {
await pushNativeTrigger(workspace, p, befObj, newObj);
} else if (typeEnding === "user") {
await pushWorkspaceUser(workspace, p, befObj, newObj);
} else if (typeEnding === "group") {
@@ -251,6 +256,7 @@ export function getTypeStrFromPath(
| "sqs_trigger"
| "gcp_trigger"
| "email_trigger"
| "native_trigger"
| "user"
| "group"
| "settings"
@@ -303,6 +309,10 @@ export function getTypeStrFromPath(
}
const typeEnding = parsed.name.split(".").at(-1);
// Check for native trigger: {service}_native_trigger pattern
if (typeEnding?.endsWith("_native_trigger")) {
return "native_trigger";
}
if (
typeEnding === "script" ||
typeEnding === "variable" ||
@@ -346,6 +356,35 @@ export function removeType(str: string, type: string) {
return normalizedStr.slice(0, normalizedStr.length - type.length - 6);
}
/**
* Extracts native trigger info from a path like:
* u/admin/script.flow.12345.nextcloud_native_trigger.json
* Returns { scriptPath: "u/admin/script", isFlow: true, externalId: "12345", serviceName: "nextcloud" }
*/
export function extractNativeTriggerInfo(p: string): {
scriptPath: string;
isFlow: boolean;
externalId: string;
serviceName: string;
} | null {
// Remove extension (.json or .yaml)
const normalizedPath = path.normalize(p).replaceAll(SEP, "/");
const withoutExt = normalizedPath.replace(/\.(json|yaml)$/, "");
// Match pattern: {script_path}.{flow|script}.{external_id}.{service}_native_trigger
const match = withoutExt.match(/^(.+)\.(flow|script)\.([^.]+)\.(\w+)_native_trigger$/);
if (!match) {
return null;
}
return {
scriptPath: match[1],
isFlow: match[2] === "flow",
externalId: match[3],
serviceName: match[4],
};
}
export function removePathPrefix(str: string, prefix: string) {
// Normalize paths for cross-platform compatibility and convert to forward slashes for API consistency
const normalizedStr = path.normalize(str).replaceAll(SEP, "/");
@@ -90,7 +90,7 @@
>Client Secret from your Nextcloud OAuth2 app configuration</span
>
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Secret' }}
inputProps={{ type: 'password', placeholder: 'Client Secret' }}
bind:value={value['secret']}
/>
</label>
@@ -10,8 +10,10 @@
import { Menu, Menubar, MeltButton, MenuItem, Tooltip } from '$lib/components/meltComponents'
import { twMerge } from 'tailwind-merge'
import SchedulePollIcon from '$lib/components/icons/SchedulePollIcon.svelte'
import type { NativeServiceName } from '$lib/gen/types.gen'
import TriggerLabel from '$lib/components/triggers/TriggerLabel.svelte'
import CountBadge from '$lib/components/common/badge/CountBadge.svelte'
import NextcloudIcon from '$lib/components/icons/NextcloudIcon.svelte'
const { triggersState, triggersCount } = getContext<TriggerContext>('TriggerContext')
@@ -42,31 +44,50 @@
}: Props = $props()
let menuOpen = $state(false)
let availableNativeServices = $state<
Array<{ service: NativeServiceName; icon: any; config: any }>
>([])
const triggerTypeConfig: {
[key in TriggerType]: {
icon: ComponentType | Component<any, {}, ''>
countKey?: string
disabled?: boolean
let triggerTypeConfig = $derived(() => {
const baseConfig: {
[key in TriggerType]?: {
icon: ComponentType | Component<any, {}, ''>
countKey?: string
disabled?: boolean
}
} = {
webhook: { icon: Webhook, countKey: 'webhook_count' },
schedule: { icon: Calendar, countKey: 'schedule_count' },
http: { icon: Route, countKey: 'http_routes_count' },
websocket: { icon: Unplug, countKey: 'websocket_count' },
postgres: { icon: Database, countKey: 'postgres_count' },
kafka: { icon: KafkaIcon, countKey: 'kafka_count', disabled: !$enterpriseLicense },
default_email: { icon: Mail, countKey: 'default_email_count' },
email: { icon: Mail, countKey: 'email_count' },
nats: { icon: NatsIcon, countKey: 'nats_count', disabled: !$enterpriseLicense },
mqtt: { icon: MqttIcon, countKey: 'mqtt_count', disabled: !$enterpriseLicense },
sqs: { icon: AwsIcon, countKey: 'sqs_count', disabled: !$enterpriseLicense },
gcp: { icon: GoogleCloudIcon, countKey: 'gcp_count', disabled: !$enterpriseLicense },
poll: { icon: SchedulePollIcon },
cli: { icon: Terminal },
nextcloud: { icon: NextcloudIcon, countKey: 'nextcloud_count' }
}
} = {
webhook: { icon: Webhook, countKey: 'webhook_count' },
schedule: { icon: Calendar, countKey: 'schedule_count' },
http: { icon: Route, countKey: 'http_routes_count' },
websocket: { icon: Unplug, countKey: 'websocket_count' },
postgres: { icon: Database, countKey: 'postgres_count' },
kafka: { icon: KafkaIcon, countKey: 'kafka_count', disabled: !$enterpriseLicense },
default_email: { icon: Mail, countKey: 'default_email_count' },
email: { icon: Mail, countKey: 'email_count' },
nats: { icon: NatsIcon, countKey: 'nats_count', disabled: !$enterpriseLicense },
mqtt: { icon: MqttIcon, countKey: 'mqtt_count', disabled: !$enterpriseLicense },
sqs: { icon: AwsIcon, countKey: 'sqs_count', disabled: !$enterpriseLicense },
gcp: { icon: GoogleCloudIcon, countKey: 'gcp_count', disabled: !$enterpriseLicense },
poll: { icon: SchedulePollIcon },
cli: { icon: Terminal }
}
const allTypes = [
// Add native trigger services that are available
for (const { service, icon } of availableNativeServices) {
baseConfig[service as TriggerType] = { icon }
}
return baseConfig as {
[key in TriggerType]: {
icon: ComponentType | Component<any, {}, ''>
countKey?: string
disabled?: boolean
}
}
})
let allTypes = $derived([
'webhook',
'schedule',
'http',
@@ -80,8 +101,9 @@
'gcp',
'email',
'poll',
'cli'
]
'cli',
'nextcloud'
])
function camelCaseToWords(s: string) {
const result = s.replace(/([A-Z])/g, ' $1')
@@ -124,10 +146,10 @@
return types.filter(
(type) =>
(!showOnlyTriggersWithCount ||
((triggerTypeConfig[type].countKey &&
($triggersCount?.[triggerTypeConfig[type].countKey] ?? 0)) ||
((triggerTypeConfig()[type]?.countKey &&
($triggersCount?.[triggerTypeConfig()[type].countKey] ?? 0)) ||
0) > 0) &&
!triggerTypeConfig[type].disabled
!triggerTypeConfig()[type]?.disabled
)
})
let triggersToDisplay = $derived(limit ? allTriggerTypes.slice(0, limit) : allTriggerTypes)
@@ -240,7 +262,10 @@
</Menubar>
{#snippet triggerButton({ type, isSelected, meltElement = undefined, singleItem = false })}
{@const { icon: SvelteComponent, countKey } = triggerTypeConfig[type]}
{@const { icon: SvelteComponent, countKey } = triggerTypeConfig()[type] || {
icon: Database,
countKey: undefined
}}
<MeltButton
aiId={`trigger-button-${type}`}
@@ -281,7 +306,10 @@
{/snippet}
{#snippet simpleTriggerItem({ item, type })}
{@const { icon: SvelteComponent, countKey } = triggerTypeConfig[type]}
{@const { icon: SvelteComponent, countKey } = triggerTypeConfig()[type] || {
icon: Database,
countKey: undefined
}}
<MenuItem {item} class={itemClass}>
<div class="flex flex-row items-center gap-2">
<SvelteComponent size={14} />
@@ -3,9 +3,17 @@
size?: number
height?: number
width?: number
color?: string | undefined
class?: string
}
let { size = undefined, height: heightProp = 24, width: widthProp = 24 }: Props = $props()
let {
size = undefined,
height: heightProp = 24,
width: widthProp = 24,
color = undefined,
class: clazz = ''
}: Props = $props()
const { width, height } = $derived(
size ? { width: size, height: size } : { width: widthProp, height: heightProp }
@@ -18,7 +26,8 @@
{width}
{height}
xmlns="http://www.w3.org/2000/svg"
style="fill:currentcolor;"
class={clazz}
fill={color ?? 'currentColor'}
>
<path
d="M12.018 6.537c-2.5 0-4.6 1.712-5.241 4.015-.56-1.232-1.793-2.105-3.225-2.105A3.569 3.569 0 0 0 0 12a3.569 3.569 0 0 0 3.552 3.553c1.432 0 2.664-.874 3.224-2.106.641 2.304 2.742 4.016 5.242 4.016 2.487 0 4.576-1.693 5.231-3.977.569 1.21 1.783 2.067 3.198 2.067A3.568 3.568 0 0 0 24 12a3.569 3.569 0 0 0-3.553-3.553c-1.416 0-2.63.858-3.199 2.067-.654-2.284-2.743-3.978-5.23-3.977zm0 2.085c1.878 0 3.378 1.5 3.378 3.378 0 1.878-1.5 3.378-3.378 3.378A3.362 3.362 0 0 1 8.641 12c0-1.878 1.5-3.378 3.377-3.378zm-8.466 1.91c.822 0 1.467.645 1.467 1.468s-.644 1.467-1.467 1.468A1.452 1.452 0 0 1 2.085 12c0-.823.644-1.467 1.467-1.467zm16.895 0c.823 0 1.468.645 1.468 1.468s-.645 1.468-1.468 1.468A1.452 1.452 0 0 1 18.98 12c0-.823.644-1.467 1.467-1.467z"
@@ -39,7 +39,8 @@
defaultNewTokenWorkspace,
scopes,
onTokenCreated,
newTokenLabel = $bindable(undefined)
newTokenLabel = $bindable(undefined),
displayCreateToken = true
}: Props = $props()
// MCP clients do not allow names longer than 60 characters, here we use 55 because final tool name server side will add ~5 characters
@@ -701,11 +702,11 @@
</div>
</div>
{#if newToken}
{#if newToken && displayCreateToken}
<TokenDisplay token={newToken} />
{/if}
{#if newMcpToken}
{#if newMcpToken && displayCreateToken}
<TokenDisplay token={newMcpToken} mcpUrl={`${mcpBaseUrl}${newMcpToken}`} />
{/if}
</div>
@@ -59,6 +59,12 @@
import NatsIcon from '../icons/NatsIcon.svelte'
import MqttIcon from '../icons/MqttIcon.svelte'
import AwsIcon from '../icons/AwsIcon.svelte'
import {
getAvailableNativeTriggerServices,
getServiceConfig,
getServiceIcon
} from '../triggers/native/utils'
import type { NativeServiceName } from '$lib/gen/types.gen'
import {
Menubar,
Menu,
@@ -86,6 +92,29 @@
let hasNewChangelogs = $state(false)
let recentChangelogs: Changelog[] = $state([])
let lastOpened = localStorage.getItem('changelogsLastOpened')
let availableNativeServices = $state<
Array<{ service: NativeServiceName; icon: any; config: any }>
>([])
async function loadAvailableNativeTriggers() {
try {
const services = await getAvailableNativeTriggerServices($workspaceStore!)
console.log({ services })
const serviceData = await Promise.all(
services.map(async (service) => ({
service,
icon: await getServiceIcon(service),
config: getServiceConfig(service)
}))
)
availableNativeServices = serviceData
} catch (err) {
console.error('Failed to load available native trigger services:', err)
availableNativeServices = []
}
}
loadAvailableNativeTriggers()
onMount(async () => {
if (lastOpened) {
@@ -317,6 +346,21 @@
aiDescription: 'Button to navigate to Email triggers'
}
])
let nativeTriggerLinks = $derived(
availableNativeServices.map(({ service, icon, config }) => ({
label: config?.serviceDisplayName || service,
href: `/native_triggers/${service}`,
icon: icon,
disabled: $userStore?.operator,
kind: service,
aiId: `sidebar-menu-link-${service}`,
aiDescription: `Button to navigate to ${config?.serviceDisplayName || service} triggers`
}))
)
let allTriggerLinks = $derived([...defaultExtraTriggerLinks, ...nativeTriggerLinks])
let triggerMenuLinks = $derived([
{
label: 'Schedules',
@@ -326,12 +370,12 @@
aiId: 'sidebar-menu-link-schedules',
aiDescription: 'Button to navigate to schedules'
},
...defaultExtraTriggerLinks.filter(
...allTriggerLinks.filter(
(link) => $usedTriggerKinds.includes(link.kind) || $page.url.pathname.includes(link.href)
)
])
let extraTriggerLinks = $derived(
defaultExtraTriggerLinks.filter((link) => {
allTriggerLinks.filter((link) => {
return !$page.url.pathname.includes(link.href) && !$usedTriggerKinds.includes(link.kind)
})
)
+1
View File
@@ -56,6 +56,7 @@ export type TriggerKind =
| 'mqtt'
| 'sqs'
| 'gcp'
| 'nextcloud'
export function captureTriggerKindToTriggerKind(kind: CaptureTriggerKind): TriggerKind {
switch (kind) {
case 'webhook':
@@ -5,7 +5,8 @@
import type { Placement } from '@floating-ui/core'
import { isCloudHosted } from '$lib/cloud'
import { CloudOff } from 'lucide-svelte'
import type { Item } from '$lib/utils'
import { isServiceAvailable } from './native/utils'
import { workspaceStore } from '$lib/stores'
interface Props {
setDropdownWidthToButtonWidth?: boolean
@@ -33,72 +34,87 @@
let dropdown: DropdownV2 | undefined
const cloudHosted = isCloudHosted()
let nextcloudAvailable = $state(false)
// Dropdown items for adding new triggers
const addTriggerItems: Item[] = [
{
displayName: 'Schedule',
action: () => onAddDraftTrigger?.('schedule'),
icon: triggerIconMap.schedule
},
{ displayName: 'HTTP', action: () => onAddDraftTrigger?.('http'), icon: triggerIconMap.http },
{
displayName: 'WebSocket',
action: () => onAddDraftTrigger?.('websocket'),
icon: triggerIconMap.websocket,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'Postgres',
action: () => onAddDraftTrigger?.('postgres'),
icon: triggerIconMap.postgres,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'Kafka',
action: () => onAddDraftTrigger?.('kafka'),
icon: triggerIconMap.kafka,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'NATS',
action: () => onAddDraftTrigger?.('nats'),
icon: triggerIconMap.nats,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'MQTT',
action: () => onAddDraftTrigger?.('mqtt'),
icon: triggerIconMap.mqtt,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'SQS',
action: () => onAddDraftTrigger?.('sqs'),
icon: triggerIconMap.sqs,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'GCP Pub/Sub',
action: () => onAddDraftTrigger?.('gcp'),
icon: triggerIconMap.gcp,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'Email',
action: () => onAddDraftTrigger?.('email'),
icon: triggerIconMap.email
},
{
displayName: 'Scheduled Poll',
action: (e) => {
onAddDraftTrigger?.('poll')
onAddScheduledPoll?.()
async function setNextcloudState() {
nextcloudAvailable = await isServiceAvailable('nextcloud', $workspaceStore!)
}
setNextcloudState()
const addTriggerItems = $derived(
[
{
displayName: 'Schedule',
action: () => onAddDraftTrigger?.('schedule'),
icon: triggerIconMap.schedule
},
icon: SchedulePollIcon,
hidden: !isEditor
}
].filter((item) => !item.hidden)
{ displayName: 'HTTP', action: () => onAddDraftTrigger?.('http'), icon: triggerIconMap.http },
{
displayName: 'WebSocket',
action: () => onAddDraftTrigger?.('websocket'),
icon: triggerIconMap.websocket,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'Postgres',
action: () => onAddDraftTrigger?.('postgres'),
icon: triggerIconMap.postgres,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'Kafka',
action: () => onAddDraftTrigger?.('kafka'),
icon: triggerIconMap.kafka,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'NATS',
action: () => onAddDraftTrigger?.('nats'),
icon: triggerIconMap.nats,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'MQTT',
action: () => onAddDraftTrigger?.('mqtt'),
icon: triggerIconMap.mqtt,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'SQS',
action: () => onAddDraftTrigger?.('sqs'),
icon: triggerIconMap.sqs,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'GCP Pub/Sub',
action: () => onAddDraftTrigger?.('gcp'),
icon: triggerIconMap.gcp,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'Email',
action: () => onAddDraftTrigger?.('email'),
icon: triggerIconMap.email,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'Scheduled Poll',
action: (e) => {
onAddDraftTrigger?.('poll')
onAddScheduledPoll?.()
},
icon: SchedulePollIcon,
hidden: !isEditor
},
{
displayName: 'Nextcloud',
action: () => onAddDraftTrigger?.('nextcloud'),
icon: triggerIconMap.nextcloud,
hidden: !nextcloudAvailable
}
].filter((item) => !item.hidden)
)
let triggersButtonWidth = $state(0)
@@ -241,6 +241,14 @@
isFlow,
$userStore
)
} else if (triggerType === 'nextcloud') {
await triggersState.fetchNativeTriggers(
'nextcloud',
$workspaceStore,
currentPath,
isFlow,
$userStore
)
}
triggersState.selectedTriggerIndex = triggersState.triggers.findIndex(
@@ -256,6 +264,7 @@
newConfig: Record<string, any>,
saveDisabled: boolean
) {
console.log('handleUpdateDraftConfig', triggerIndex, newConfig, saveDisabled)
if (triggerIndex && triggerIndex !== -1 && newConfig) {
triggersState.setDraftConfig(triggerIndex, { ...newConfig, canSave: !saveDisabled })
}
@@ -16,6 +16,7 @@
import ClipboardPanel from '../details/ClipboardPanel.svelte'
import CliHelpBox from '../CliHelpBox.svelte'
import TriggerLabel from './TriggerLabel.svelte'
import NativeTriggersPanel from './native/NativeTriggersPanel.svelte'
interface Props {
selectedTrigger: Trigger
@@ -52,6 +53,10 @@
onEmailDomain,
...props
}: Props = $props()
$effect(() => {
console.log('selectedTrigger', selectedTrigger)
})
</script>
{#if selectedTrigger.type === 'http'}
@@ -167,6 +172,16 @@
/>
{:else if selectedTrigger.type === 'poll'}
<ScheduledPollPanel />
{:else if selectedTrigger.type === 'nextcloud'}
<NativeTriggersPanel
service="nextcloud"
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
{customLabel}
{...props}
/>
{:else if selectedTrigger.type === 'cli'}
<div class="py-1 flex flex-col gap-6">
<ClipboardPanel content={selectedTrigger.extra?.cliCommand ?? ''} />
@@ -0,0 +1,459 @@
<script lang="ts">
import { NativeTriggerService } from '$lib/gen/services.gen'
import type { NativeServiceName } from '$lib/gen/types.gen'
import type { ExtendedNativeTrigger } from './utils'
import {
validateCommonFields,
getServiceConfig,
getTemplatePath,
saveNativeTriggerFromCfg
} from './utils'
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { canWrite, emptyString, sendUserToast } from '$lib/utils'
import { Button } from '$lib/components/common'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import { Loader2, Save } from 'lucide-svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import Section from '$lib/components/Section.svelte'
import Required from '$lib/components/Required.svelte'
import NextcloudTriggerForm from './services/nextcloud/NextcloudTriggerForm.svelte'
import TriggerEditorToolbar from '$lib/components/triggers/TriggerEditorToolbar.svelte'
import { handleConfigChange, type Trigger } from '$lib/components/triggers/utils'
import { deepEqual } from 'fast-equals'
import type { Snippet } from 'svelte'
interface Props {
service: NativeServiceName
onUpdate?: (path?: string) => void
useDrawer?: boolean
hideTarget?: boolean
allowDraft?: boolean
trigger?: Trigger
customLabel?: Snippet
isDeployed?: boolean
cloudDisabled?: boolean
description?: Snippet
onConfigChange?: (cfg: Record<string, any>, saveDisabled: boolean, updated: boolean) => void
onDelete?: () => void
onReset?: () => void
}
let {
service,
onUpdate,
useDrawer = true,
hideTarget = false,
allowDraft = false,
trigger = undefined,
customLabel,
isDeployed = false,
cloudDisabled = false,
description,
onConfigChange,
onDelete,
onReset
}: Props = $props()
const serviceInfo = $derived(getServiceConfig(service))
const scriptTemplateUrl = $derived.by(() => {
const templateId = getTemplatePath(service, 'script')
return templateId
})
const flowTemplateUrl = $derived.by(() => {
const templateId = getTemplatePath(service, 'flow')
return templateId
})
let serviceFormRef = $state<any>(null)
const ServiceFormComponent = $derived.by(() => {
switch (service) {
case 'nextcloud':
return NextcloudTriggerForm
default:
return null
}
})
let drawer: Drawer | undefined = $state()
let isNew = $state(false)
let isRecreate = $state(false)
let oldExternalIdToDelete = $state<string | null>(null)
let loading = $state(false)
let loadingConfig = $state(false)
let loadingForm = $state(false)
let showLoading = $state(false)
let serviceConfig = $state<Record<string, any>>({})
let externalData = $state<any>(null)
let errors = $state<Record<string, string>>({})
let scriptPath = $state('')
let initialScriptPath = $state('')
let fixedScriptPath = $state('')
let isFlow = $state(false)
let externalId = $state<string | null>(null)
let can_write = $state(true)
let originalConfig = $state<Record<string, any> | undefined>(undefined)
let initialConfig: Record<string, any> | undefined = undefined
export function openNew(
nis_flow?: boolean,
fixedScriptPath_?: string,
defaultValues?: Record<string, any>
) {
if (useDrawer) {
drawer?.openDrawer()
}
isNew = true
isRecreate = false
oldExternalIdToDelete = null
serviceConfig = defaultValues ? { ...defaultValues } : {}
externalData = defaultValues ? { ...defaultValues } : null
errors = {}
fixedScriptPath = fixedScriptPath_ ?? ''
scriptPath = fixedScriptPath
initialScriptPath = ''
isFlow = nis_flow ?? false
externalId = null
loadingConfig = false
can_write = true
originalConfig = undefined
initialConfig = undefined
}
export function openRecreate(nativeTrigger: ExtendedNativeTrigger) {
if (useDrawer) {
drawer?.openDrawer()
}
isNew = true
isRecreate = true
oldExternalIdToDelete = nativeTrigger.external_id
const cachedConfig = (nativeTrigger.service_config as Record<string, any>) || {}
serviceConfig = { ...cachedConfig }
// Pass cached config as externalData so the form applies it after loading events
externalData = { ...cachedConfig }
errors = {}
scriptPath = nativeTrigger.script_path
initialScriptPath = nativeTrigger.script_path
isFlow = nativeTrigger.is_flow
externalId = null
loadingConfig = false
loadingForm = false
can_write = true
originalConfig = undefined
initialConfig = undefined
}
export async function openEdit(
externalIdOrPath: string,
nis_flow?: boolean,
defaultValues?: Record<string, any>
) {
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100)
if (useDrawer) {
drawer?.openDrawer()
}
isNew = false
isRecreate = false
oldExternalIdToDelete = null
externalData = null
errors = {}
externalId = externalIdOrPath
loadingConfig = true
loadingForm = true
initialConfig = undefined
try {
const fullTrigger = await NativeTriggerService.getNativeTrigger({
workspace: $workspaceStore!,
serviceName: service,
externalId: externalIdOrPath
})
serviceConfig = (fullTrigger.service_config as Record<string, any>) || {}
scriptPath = fullTrigger.script_path
initialScriptPath = fullTrigger.script_path
isFlow = nis_flow ?? fullTrigger.is_flow
can_write = canWrite(fullTrigger.script_path, {}, $userStore)
externalData = fullTrigger.external_data
// Apply default values if provided (for draft triggers)
if (defaultValues) {
serviceConfig = { ...serviceConfig, ...defaultValues }
externalData = { ...externalData, ...defaultValues }
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
} catch (err: any) {
sendUserToast(`Failed to load trigger configuration: ${err}`, true)
externalData = null
} finally {
// For drawer mode, set initialConfig here; for inline mode, the effect handles it after form settles
if (!defaultValues) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
clearTimeout(loadingTimeout)
loadingConfig = false
showLoading = false
}
}
function getSaveCfg(): Record<string, any> {
return {
script_path: scriptPath,
is_flow: isFlow,
service_config: serviceConfig
}
}
function close() {
drawer?.closeDrawer()
}
let validationErrors = $derived.by(() => {
if (!serviceFormRef) {
return {}
}
const commonErrors = validateCommonFields({
script_path: scriptPath
})
let serviceErrors: Record<string, string> = {}
if (serviceFormRef?.validate) {
serviceErrors = serviceFormRef.validate()
}
return { ...commonErrors, ...serviceErrors }
})
let isValid = $derived.by(() => Object.keys(validationErrors).length === 0)
let hasChanged = $derived(!deepEqual(getSaveCfg(), originalConfig ?? {}))
$effect(() => {
console.log('loading', getSaveCfg(), originalConfig)
})
let saveDisabled = $derived(
loading ||
!isValid ||
emptyString(scriptPath) ||
loadingConfig ||
loadingForm ||
!can_write ||
!hasChanged
)
const saveCfg = $derived.by(getSaveCfg)
$effect(() => {
if (!loadingConfig && !loadingForm) {
handleConfigChange(saveCfg, initialConfig, saveDisabled, !isNew, onConfigChange)
}
})
async function save(): Promise<void> {
loading = true
const saveCfg = getSaveCfg()
const newExternalId = await saveNativeTriggerFromCfg(
service,
externalId ?? '',
saveCfg,
!isNew,
$workspaceStore!,
usedTriggerKinds
)
if (newExternalId) {
if (isNew) {
externalId = newExternalId
isNew = false
if (isRecreate && oldExternalIdToDelete) {
try {
await NativeTriggerService.deleteNativeTrigger({
workspace: $workspaceStore!,
serviceName: service,
externalId: oldExternalIdToDelete
})
sendUserToast(
`${serviceInfo?.serviceDisplayName} trigger recreated (old trigger deleted)`
)
} catch (deleteErr: any) {
// Still show success for creation, but warn about deletion failure
sendUserToast(
`${serviceInfo?.serviceDisplayName} trigger created, but failed to delete old trigger: ${deleteErr.body || deleteErr.message}`,
true
)
}
}
}
onUpdate?.(saveCfg.path)
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
initialScriptPath = saveCfg.script_path
if (useDrawer) {
close()
}
}
loading = false
}
let templateUrl = $derived(isFlow ? flowTemplateUrl : scriptTemplateUrl)
let itemKind: 'flow' | 'script' = $derived(isFlow ? 'flow' : 'script')
</script>
{#if useDrawer}
<Drawer size="800px" bind:this={drawer}>
<DrawerContent
title={isRecreate
? `Recreate ${serviceInfo?.serviceDisplayName} trigger`
: isNew
? `New ${serviceInfo?.serviceDisplayName} trigger`
: `Edit ${serviceInfo?.serviceDisplayName} trigger`}
on:close={drawer?.closeDrawer}
>
{#snippet actions()}
{@render drawerActions()}
{/snippet}
{@render content()}
</DrawerContent>
</Drawer>
{:else}
<Section
label={!customLabel ? `${serviceInfo?.serviceDisplayName} trigger` : ''}
headerClass="grow min-w-0 h-[30px]"
>
{#snippet header()}
{#if customLabel}
{@render customLabel()}
{/if}
{/snippet}
{#snippet action()}
{@render inlineActions()}
{/snippet}
{@render content()}
</Section>
{/if}
{#snippet drawerActions()}
<Button
size="sm"
startIcon={{ icon: Save }}
variant="accent"
on:click={save}
disabled={saveDisabled}
{loading}
>
Save
</Button>
{/snippet}
{#snippet inlineActions()}
{#if !loadingConfig}
<TriggerEditorToolbar
{trigger}
permissions={loadingConfig || !can_write ? 'none' : 'create'}
mode="enabled"
{allowDraft}
edit={!isNew}
isLoading={loading}
{isDeployed}
{saveDisabled}
onUpdate={save}
{onReset}
{onDelete}
{cloudDisabled}
onToggleMode={() => {}}
disableSuspendedMode={true}
/>
{/if}
{/snippet}
{#snippet content()}
{#if loadingConfig && showLoading}
<Loader2 class="animate-spin" />
{:else}
<div class="flex flex-col gap-4">
{#if description}
{@render description()}
{/if}
</div>
<div class="flex flex-col gap-12 mt-6">
{#if !hideTarget}
<Section label="Runnable">
<p class="text-xs mb-1 text-primary">
Pick a script or flow to be triggered<Required required={true} />
</p>
<div class="flex flex-row mb-2">
<ScriptPicker
disabled={fixedScriptPath != '' || !can_write}
initialPath={fixedScriptPath || initialScriptPath}
bind:scriptPath
allowRefresh={can_write}
bind:itemKind
on:select={(e) => {
isFlow = e.detail.itemKind === 'flow'
}}
kinds={['script']}
allowFlow={true}
allowEdit={!$userStore?.operator}
clearable
/>
{#if emptyString(scriptPath)}
<Button
btnClasses="ml-4"
variant="accent"
size="xs"
href={templateUrl}
target="_blank"
disabled={!can_write}
>
Create from template
</Button>
{/if}
</div>
{#if errors.runnable_path}
<div class="text-red-500 text-xs mt-1">{errors.runnable_path}</div>
{/if}
</Section>
{/if}
{#if loadingConfig}
<Section label="{serviceInfo?.serviceDisplayName} configuration">
<div class="flex items-center gap-2 text-secondary text-xs">
<Loader2 class="animate-spin" size={16} />
Loading configuration from {serviceInfo?.serviceDisplayName}...
</div>
</Section>
{:else if ServiceFormComponent}
<ServiceFormComponent
bind:loading={loadingForm}
bind:this={serviceFormRef}
bind:serviceConfig
bind:errors
{externalData}
disabled={loading || loadingConfig || !can_write}
path={scriptPath}
{isFlow}
token=""
triggerTokens={undefined}
scopes={[]}
/>
{:else}
<Section label="{serviceInfo?.serviceDisplayName} configuration">
<div class="text-red-500 text-xs space-y-2">
<div>Failed to load service configuration component for {service}.</div>
<div class="text-secondary">
Ensure your workspace has a connected {serviceInfo?.serviceDisplayName} integration.
</div>
<Button
unifiedSize="xs"
variant="default"
href="/workspace_settings?tab=integrations"
target="_blank"
>
Manage integrations
</Button>
</div>
</Section>
{/if}
</div>
{/if}
{/snippet}
@@ -0,0 +1,175 @@
<script lang="ts">
import { NativeTriggerService } from '$lib/gen/services.gen'
import type { NativeServiceName } from '$lib/gen/types.gen'
import type { ExtendedNativeTrigger } from './utils'
import { getServiceConfig } from './utils'
import { sendUserToast } from '$lib/utils'
import { workspaceStore } from '$lib/stores'
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import Dropdown from '$lib/components/DropdownV2.svelte'
import { Eye, Pen, RefreshCw, Trash } from 'lucide-svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import { goto } from '$lib/navigation'
import Alert from '$lib/components/common/alert/Alert.svelte'
type TriggerW = ExtendedNativeTrigger & { marked?: any }
interface Props {
service: NativeServiceName
triggers: TriggerW[]
loading?: boolean
onEdit?: (trigger: TriggerW) => void
onRecreate?: (trigger: TriggerW) => void
onSync?: () => Promise<void>
}
let { service, triggers = [], loading = false, onEdit, onRecreate }: Props = $props()
const serviceConfig = $derived(getServiceConfig(service))
let deleteConfirmationOpen = $state(false)
let triggerToDelete = $state<ExtendedNativeTrigger | null>(null)
let isDeleting = $state(false)
function openDeleteConfirmation(trigger: ExtendedNativeTrigger) {
triggerToDelete = trigger
deleteConfirmationOpen = true
}
function closeDeleteConfirmation() {
deleteConfirmationOpen = false
triggerToDelete = null
isDeleting = false
}
async function confirmDeleteTrigger() {
if (!triggerToDelete) return
isDeleting = true
try {
await NativeTriggerService.deleteNativeTrigger({
workspace: $workspaceStore!,
serviceName: service,
externalId: triggerToDelete.external_id
})
sendUserToast(`${serviceConfig?.serviceDisplayName} trigger deleted`)
triggers = triggers.filter(
(native_trigger) => native_trigger.external_id !== triggerToDelete?.external_id
)
closeDeleteConfirmation()
} catch (err: any) {
sendUserToast(`Failed to delete trigger: ${err.body || err.message}`, true)
isDeleting = false
}
}
</script>
<div class="w-full">
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !triggers?.length}
<div class="text-center text-sm font-semibold text-emphasis mt-2">
No {serviceConfig?.serviceDisplayName} triggers
</div>
{:else}
<div class="border rounded-md divide-y">
{#each triggers as trigger (trigger.external_id)}
{@const isFlow = trigger.is_flow}
{@const href = `${isFlow ? '/flows/get' : '/scripts/get'}/${trigger.script_path}`}
<div
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-5 items-center">
<RowIcon kind={isFlow ? 'flow' : 'script'} />
<a
href="#{trigger.external_id}"
onclick={() => onEdit?.(trigger)}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate">
{#if trigger.marked}
<span class="text-xs">
{@html trigger.marked}
</span>
{:else}
{trigger.script_path}
{/if}
</div>
<div class="text-secondary text-xs truncate text-left font-normal">
external ID: {trigger.external_id}
</div>
</a>
<div class="flex gap-2 items-center justify-end">
<Button
on:click={() => onEdit?.(trigger)}
unifiedSize="md"
startIcon={{ icon: Pen }}
variant="subtle"
>
Edit
</Button>
<Dropdown
size="md"
items={[
{
displayName: `View ${isFlow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
goto(href)
}
},
{
displayName: 'Delete',
type: 'delete' as const,
icon: Trash,
action: () => openDeleteConfirmation(trigger)
}
]}
/>
</div>
</div>
{#if trigger.error}
<div class="w-full flex justify-between items-center gap-2">
<div class="text-2xs font-normal text-red-500 truncate">
Error: {trigger.error}
</div>
<Button
size="xs"
variant="subtle"
startIcon={{ icon: RefreshCw }}
on:click={() => onRecreate?.(trigger)}
>
Recreate
</Button>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<ConfirmationModal
open={deleteConfirmationOpen}
title="Delete {serviceConfig?.serviceDisplayName} trigger"
confirmationText="Delete"
loading={isDeleting}
onConfirmed={confirmDeleteTrigger}
onCanceled={closeDeleteConfirmation}
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to delete this trigger?</span>
{#if triggerToDelete?.external_id}
<span>External ID: {triggerToDelete.external_id}</span>
{/if}
<Alert type="warning" title="Warning">
This will permanently delete the trigger from both Windmill and {serviceConfig?.serviceDisplayName}.
This action cannot be undone.
</Alert>
</div>
</ConfirmationModal>
@@ -0,0 +1,66 @@
<script lang="ts">
import NativeTriggerEditor from './NativeTriggerEditor.svelte'
import Description from '$lib/components/Description.svelte'
import { onMount, type Snippet } from 'svelte'
import type { NativeServiceName } from '$lib/gen'
import type { Trigger } from '../utils'
import { NATIVE_TRIGGER_SERVICES } from './utils'
interface Props {
service: NativeServiceName
selectedTrigger: Trigger
isFlow: boolean
path: string
isDeployed?: boolean
defaultValues?: Record<string, any>
customLabel?: Snippet
}
let {
service,
selectedTrigger,
isFlow,
path,
isDeployed = false,
defaultValues = undefined,
customLabel = undefined,
...restProps
}: Props = $props()
let editor: NativeTriggerEditor | undefined = $state(undefined)
async function openEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
editor?.openNew(isFlow, path, defaultValues)
} else {
// selectedTrigger.path contains the external_id for native triggers
editor?.openEdit(selectedTrigger.path!, isFlow, defaultValues)
}
}
onMount(() => {
openEditor(isFlow, selectedTrigger.isDraft ?? false)
})
const serviceDisplayName = $derived(NATIVE_TRIGGER_SERVICES[service].serviceDisplayName)
</script>
<div class="flex flex-col gap-4">
<NativeTriggerEditor
bind:this={editor}
{service}
useDrawer={false}
hideTarget
allowDraft={true}
trigger={selectedTrigger}
{customLabel}
{isDeployed}
{...restProps}
>
{#snippet description()}
<Description link="https://www.windmill.dev/docs/core_concepts/{service}_triggers">
{serviceDisplayName} triggers execute scripts and flows in response to events in {serviceDisplayName}.
</Description>
{/snippet}
</NativeTriggerEditor>
</div>
@@ -0,0 +1,138 @@
<script lang="ts">
import { NativeTriggerService } from '$lib/gen/services.gen'
import type { NextCloudEventType } from '$lib/gen/types.gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/utils'
import { Button } from '$lib/components/common'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import Section from '$lib/components/Section.svelte'
import { Loader2 } from 'lucide-svelte'
import { getNextcloudSchema } from '../../utils'
interface Props {
serviceConfig: Record<string, any>
errors: Record<string, string>
disabled?: boolean
externalData?: any
path?: string
isFlow?: boolean
hash?: string
token?: string
triggerTokens?: any
scopes?: string[]
loading: boolean
}
let {
serviceConfig = $bindable(),
errors = $bindable(),
disabled = false,
externalData = undefined,
token = $bindable(''),
triggerTokens = $bindable(undefined),
loading = $bindable()
}: Props = $props()
let availableEvents = $state<NextCloudEventType[]>([])
let serviceSchema = $state<any>(null)
async function loadAvailableEvents() {
if (!$workspaceStore) {
availableEvents = []
return
}
loading = true
try {
const events = await NativeTriggerService.listNextCloudEvents({
workspace: $workspaceStore!
})
availableEvents = events
serviceSchema = getNextcloudSchema(events)
} catch (err: any) {
console.error('Failed to load NextCloud events:', err)
sendUserToast(`Failed to load available events: ${err.body || err.message}`, true)
availableEvents = []
} finally {
loading = false
}
}
export function validate(): Record<string, string> {
let serviceErrors: Record<string, string> = {}
if (serviceSchema && serviceSchema.required) {
for (const requiredField of serviceSchema.required) {
if (!serviceConfig[requiredField]) {
const fieldTitle = serviceSchema.properties?.[requiredField]?.title || requiredField
serviceErrors[requiredField] = `${fieldTitle} is required`
}
}
}
if (!serviceConfig.event && availableEvents.length === 0 && !loading) {
serviceErrors.event =
'Unable to load available events. Please check your resource configuration.'
}
return serviceErrors
}
let externalDataApplied = $state(false)
$effect(() => {
if ($workspaceStore) {
loadAvailableEvents()
}
})
$effect(() => {
if (externalData && !externalDataApplied) {
serviceConfig = { ...serviceConfig, ...externalData }
externalDataApplied = true
}
})
$effect(() => {
if (!externalData) {
externalDataApplied = false
}
})
</script>
<Section label="Nextcloud configuration">
{#if loading}
<div class="flex items-center gap-2 text-secondary text-xs">
<Loader2 class="animate-spin" size={16} />
Loading available events...
</div>
{:else if availableEvents.length === 0}
<div class="text-red-500 text-xs space-y-2">
<div
>No events available. Please ensure your workspace has a connected Nextcloud integration.</div
>
<div class="flex gap-2">
<Button variant="default" on:click={loadAvailableEvents} {disabled}>
Retry loading events
</Button>
<Button variant="subtle" href="/workspace_settings?tab=integrations" target="_blank">
Manage integrations
</Button>
</div>
</div>
{:else if serviceSchema}
<SchemaForm
schema={serviceSchema}
bind:args={serviceConfig}
isValid={true}
compact={true}
prettifyHeader={true}
{disabled}
/>
{:else}
<div class="text-secondary text-xs">
Please ensure Nextcloud workspace integration is connected to load available configuration
options.
</div>
{/if}
</Section>
@@ -0,0 +1,221 @@
import type { NativeServiceName, NativeTrigger, NativeTriggerData } from '$lib/gen/types.gen'
import { isCloudHosted } from '$lib/cloud'
import { NativeTriggerService, WorkspaceIntegrationService } from '$lib/gen'
import { get, type Writable } from 'svelte/store'
import { sendUserToast } from '$lib/toast'
export interface NativeTriggerConfig {
readonly serviceDisplayName: string
readonly serviceKey: NativeServiceName
readonly supportsSync: boolean
readonly supportsFetchConfig: boolean
readonly isCloudCompatible: boolean
readonly templates?: {
script?: string
flow?: string
}
}
export const NATIVE_TRIGGER_SERVICES: Record<NativeServiceName, NativeTriggerConfig> = {
nextcloud: {
serviceDisplayName: 'Nextcloud',
serviceKey: 'nextcloud',
supportsSync: true,
supportsFetchConfig: true,
isCloudCompatible: true,
templates: {
script: '/scripts/add?hub=hub%2F28115',
flow: '/flows/add?hub=73'
}
}
}
export async function isServiceAvailable(
service: NativeServiceName,
workspace: string
): Promise<boolean> {
const config = NATIVE_TRIGGER_SERVICES[service]
if (!config) return false
if (isCloudHosted() && !config.isCloudCompatible) {
return false
}
try {
const response = await WorkspaceIntegrationService.checkIfNativeTriggersServiceExists({
workspace,
serviceName: service
})
return response
} catch (workspaceErr) {
console.debug(`Workspace integration check failed for ${service}:`, workspaceErr)
return false
}
}
export function getAvailableServices(): NativeServiceName[] {
return Object.keys(NATIVE_TRIGGER_SERVICES) as NativeServiceName[]
}
export async function getAvailableNativeTriggerServices(
workspace: string
): Promise<NativeServiceName[]> {
const services = getAvailableServices()
const availableServices: NativeServiceName[] = []
for (const service of services) {
const available = await isServiceAvailable(service, workspace)
if (available) {
availableServices.push(service)
}
}
return availableServices
}
export function getServiceConfig(service: NativeServiceName): NativeTriggerConfig | undefined {
return NATIVE_TRIGGER_SERVICES[service]
}
// NativeTrigger now has script_path and is_flow directly
// This type adds the marked property for search highlighting
export type ExtendedNativeTrigger = NativeTrigger & { marked?: string }
export interface ServiceFormProps {
config: Record<string, any>
errors: Record<string, string>
resources: Array<{ path: string; description?: string }>
onConfigChange: (newConfig: Record<string, any>) => void
onTest?: () => Promise<void>
disabled?: boolean
}
export function validateCommonFields(config: Record<string, any>): Record<string, string> {
const errors: Record<string, string> = {}
if (!config.script_path?.trim()) {
errors.script_path = 'Script/Flow path is required'
}
return errors
}
export function formatTriggerDisplayName(trigger: NativeTrigger): string {
return `${trigger.script_path} (external id: ${trigger.external_id})`
}
export function getTriggerIconName(service: NativeServiceName): string {
switch (service) {
case 'nextcloud':
return 'NextcloudIcon'
default:
return 'NextcloudIcon'
}
}
export async function getServiceIcon(service: NativeServiceName): Promise<any> {
switch (service) {
case 'nextcloud':
return (await import('$lib/components/icons/NextcloudIcon.svelte')).default
}
}
export function getServiceTemplates(
service: NativeServiceName
): { script?: string; flow?: string } | undefined {
const config = getServiceConfig(service)
return config?.templates
}
export function getTemplatePath(
service: NativeServiceName,
type: 'script' | 'flow'
): string | undefined {
const templates = getServiceTemplates(service)
return templates?.[type]
}
export interface NextcloudEvent {
path: string
description?: string
}
export function getNextcloudSchema(availableEvents: NextcloudEvent[]) {
return {
type: 'object',
properties: {
event: {
type: 'string',
title: 'Event',
description: 'The type of Nextcloud event to listen for',
enum: availableEvents.map((e) => e.path),
enumLabels: availableEvents.reduce(
(acc, cur) => ({ ...acc, [cur.path]: cur.description ?? cur.path }),
{} as Record<string, string>
)
},
eventFilter: {
type: 'object',
title: 'Event filter',
description: 'Optional filter criteria for the event (JSON object)'
},
userIdFilter: {
type: 'string',
title: 'User ID filter',
description: 'Filter events by specific user ID'
},
headers: {
type: 'object',
title: 'Headers',
description: 'Optional HTTP headers to include (JSON object)'
}
},
required: ['event']
}
}
export async function saveNativeTriggerFromCfg(
service: NativeServiceName,
initialExternalId: string,
triggerCfg: Record<string, any>,
edit: boolean,
workspace: string,
usedTriggerKinds: Writable<string[]>
): Promise<string | null> {
const requestBody: NativeTriggerData = {
script_path: triggerCfg.script_path,
is_flow: triggerCfg.is_flow,
service_config: triggerCfg.service_config
}
const serviceName = NATIVE_TRIGGER_SERVICES[service].serviceDisplayName
try {
let externalId = initialExternalId
if (edit) {
await NativeTriggerService.updateNativeTrigger({
workspace: workspace,
serviceName: service,
externalId: initialExternalId,
requestBody
})
sendUserToast(`${serviceName} trigger ${externalId} updated`)
} else {
const response = await NativeTriggerService.createNativeTrigger({
workspace: workspace,
serviceName: service,
requestBody
})
externalId = response.external_id
sendUserToast(`${serviceName} trigger ${externalId} created`)
}
if (!get(usedTriggerKinds).includes(service)) {
usedTriggerKinds.update((t) => [...t, service])
}
return externalId
} catch (error) {
sendUserToast(error.body || error.message, true)
return null
}
}
@@ -6,6 +6,7 @@ import {
ScheduleService,
SqsTriggerService,
WebsocketTriggerService,
NativeTriggerService,
type GcpTrigger,
type KafkaTrigger,
type PostgresTrigger,
@@ -15,7 +16,9 @@ import {
HttpTriggerService,
GcpTriggerService,
type EmailTrigger,
EmailTriggerService
EmailTriggerService,
type NativeTrigger,
type NativeServiceName
} from '$lib/gen'
import { enterpriseLicense } from '$lib/stores'
@@ -73,6 +76,7 @@ export class Triggers {
}
setDraftConfig(triggerIndex: number, draftConfig: Record<string, any> | undefined) {
console.log('setDraftConfig', triggerIndex, draftConfig)
if (triggerIndex === undefined || triggerIndex < 0 || triggerIndex >= this.#triggers.length) {
return
}
@@ -460,6 +464,37 @@ export class Triggers {
}
}
async fetchNativeTriggers(
serviceName: NativeServiceName,
workspaceId: string | undefined,
path: string,
isFlow: boolean,
user: UserExt | undefined = undefined
): Promise<void> {
if (!workspaceId) return
try {
const allTriggers: NativeTrigger[] = await NativeTriggerService.listNativeTriggers({
workspace: workspaceId,
serviceName
})
// Filter triggers for this specific script/flow path
const filteredTriggers = allTriggers.filter(
(t) => t.script_path === path && t.is_flow === isFlow
)
// Convert to the trigger format used by updateTriggers
const triggerData = filteredTriggers.map((t) => ({
path: t.external_id,
script_path: t.script_path,
is_flow: t.is_flow,
extra_perms: {},
service_config: t.service_config
}))
this.updateTriggers(triggerData, serviceName, user)
} catch (error) {
console.debug(`Failed to fetch ${serviceName} triggers:`, error)
}
}
async fetchTriggers(
triggersCountStore: Writable<TriggersCount | undefined>,
workspaceId: string | undefined,
@@ -479,10 +514,14 @@ export class Triggers {
this.fetchNatsTriggers(triggersCountStore, workspaceId, path, isFlow, user),
this.fetchMqttTriggers(triggersCountStore, workspaceId, path, isFlow, user),
this.fetchEmailTriggers(triggersCountStore, workspaceId, path, isFlow, user),
...(get(enterpriseLicense) ? [
this.fetchKafkaTriggers(triggersCountStore, workspaceId, path, isFlow, user),
this.fetchSqsTriggers(triggersCountStore, workspaceId, path, isFlow, user),
this.fetchGcpTriggers(triggersCountStore, workspaceId, path, isFlow, user)] : [])
this.fetchNativeTriggers('nextcloud', workspaceId, path, isFlow, user),
...(get(enterpriseLicense)
? [
this.fetchKafkaTriggers(triggersCountStore, workspaceId, path, isFlow, user),
this.fetchSqsTriggers(triggersCountStore, workspaceId, path, isFlow, user),
this.fetchGcpTriggers(triggersCountStore, workspaceId, path, isFlow, user)
]
: [])
])
}
}
+25 -5
View File
@@ -27,6 +27,8 @@ import { saveGcpTriggerFromCfg } from './gcp/utils'
import type { Triggers } from './triggers.svelte'
import { emptyString } from '$lib/utils'
import { saveEmailTriggerFromCfg } from './email/utils'
import NextcloudIcon from '$lib/components/icons/NextcloudIcon.svelte'
import { saveNativeTriggerFromCfg } from './native/utils'
export const CLOUD_DISABLED_TRIGGER_TYPES = [
'nats',
@@ -54,6 +56,7 @@ export type TriggerType =
| 'email'
| 'poll'
| 'cli'
| 'nextcloud'
export const jobTriggerKinds: JobTriggerKind[] = [
'webhook',
@@ -99,7 +102,8 @@ export const triggerIconMap = {
gcp: GoogleCloudIcon,
primary_schedule: Calendar,
poll: SchedulePollIcon,
cli: Terminal
cli: Terminal,
nextcloud: NextcloudIcon
}
export const triggerDisplayNamesMap = {
@@ -116,7 +120,8 @@ export const triggerDisplayNamesMap = {
poll: 'Scheduled Poll',
webhook: 'Webhook',
default_email: 'Default Email',
cli: 'CLI'
cli: 'CLI',
nextcloud: 'Nextcloud'
} as const satisfies Record<TriggerType, string>
/**
@@ -170,7 +175,8 @@ export function updateTriggersCount(
gcp: 'gcp_count',
email: 'email_count',
poll: undefined,
cli: undefined
cli: undefined,
nextcloud: undefined
}
const countProperty = countPropertyMap[type]
@@ -352,7 +358,16 @@ export async function deployTriggers(
usedTriggerKinds
),
poll: undefined,
cli: undefined
cli: undefined,
nextcloud: (trigger: Trigger) =>
saveNativeTriggerFromCfg(
'nextcloud',
trigger.path ?? '',
trigger.draftConfig ?? {},
!trigger.isDraft,
workspaceId,
usedTriggerKinds
)
}
await Promise.all(
@@ -450,6 +465,8 @@ export function getLightConfig(
return { gcp_resource_path: trigger.gcp_resource_path, topic: trigger.topic }
} else if (triggerType === 'email') {
return { local_part: trigger.local_part }
} else if (triggerType === 'nextcloud') {
return { event: trigger.service_config?.event ?? trigger.event }
} else {
return undefined
}
@@ -484,6 +501,8 @@ export function getTriggerLabel(trigger: Trigger): string {
return `${config?.url}`
} else if (type === 'email' && config?.local_part) {
return `${config?.local_part}`
} else if (type === 'nextcloud' && path) {
return `${path}`
} else if (isDraft && draftConfig?.path) {
return `${draftConfig?.path}`
} else if (isDraft) {
@@ -508,7 +527,8 @@ export function sortTriggers(triggers: Trigger[]): Trigger[] {
'mqtt',
'sqs',
'gcp',
'email'
'email',
'nextcloud'
]
return triggers.sort((a, b) => {
@@ -0,0 +1,204 @@
<script lang="ts">
import { sendUserToast } from '$lib/utils'
import { Button, Alert } from '$lib/components/common'
import Label from '$lib/components/Label.svelte'
import { Save, ExternalLink } from 'lucide-svelte'
import { workspaceStore } from '$lib/stores'
interface Props {
serviceName: string
serviceDisplayName: string
existingConfig?: {
client_id: string
client_secret: string
base_url: string
} | null
redirectUri: string
onConfigSaved: (oauthData: any) => Promise<void>
}
let {
serviceName,
serviceDisplayName,
existingConfig,
onConfigSaved,
redirectUri = $bindable('')
}: Props = $props()
let saving = $state(false)
let clientId = $state('')
let clientSecret = $state('')
let baseUrl = $state('')
let workspace = $workspaceStore!
redirectUri = `${window.location.origin}/workspace_settings?tab=native_triggers&service=${serviceName}&workspace=${workspace}`
$effect(() => {
if (existingConfig) {
clientId = existingConfig.client_id
clientSecret = existingConfig.client_secret
baseUrl = existingConfig.base_url
}
})
async function saveConfig() {
if (!clientId.trim() || !clientSecret.trim() || !baseUrl.trim()) {
sendUserToast('Please fill in all fields', true)
return
}
saving = true
try {
const oauthData = {
client_id: clientId.trim(),
client_secret: clientSecret.trim(),
base_url: baseUrl.trim().replace(/\/+$/, ''),
redirect_uri: redirectUri.trim()
}
await onConfigSaved(oauthData)
resetForm()
} catch (err: any) {
sendUserToast(`Failed to configure OAuth client: ${err.message}`, true)
} finally {
saving = false
}
}
function resetForm() {
clientId = ''
clientSecret = ''
}
function copyToClipboard(text: string, label: string) {
navigator.clipboard
.writeText(text)
.then(() => {
sendUserToast(`${label} copied to clipboard`)
})
.catch(() => {
sendUserToast(`Failed to copy ${label}`, true)
})
}
</script>
<div class="space-y-4">
<div class="flex justify-between items-center">
<h4 class="text-md font-medium text-primary">{serviceDisplayName} OAuth Client Configuration</h4
>
</div>
{#if !existingConfig}
<Alert type="warning" title="OAuth client required">
<p class="text-sm mb-2">
Before you can connect to {serviceDisplayName}, you need to configure an OAuth client. This
requires:
</p>
<ol class="list-decimal list-inside space-y-1 text-sm">
<li
>Create an OAuth2 application in your {serviceDisplayName} instance (Administration settings
-> Security -> OAuth 2.0 clients)</li
>
<li
>Configure the redirect URI: <code
class="text-xs bg-gray-100 dark:bg-gray-800 px-1 rounded"
>{redirectUri ||
`${window.location.origin}/workspace_settings?tab=integrations&service=${serviceName}`}</code
></li
>
<li>Enter the client credentials below</li>
</ol>
</Alert>
{/if}
<div class="space-y-4">
<div class="space-y-4">
<div class="flex flex-col gap-1">
<Label label="Nextcloud base URL" />
<p class="text-xs font-normal text-secondary"
>The base URL of your Nextcloud instance (without trailing slash)</p
>
<input
type="url"
bind:value={baseUrl}
placeholder="https://your-nextcloud.example.com"
class="windmill-input"
disabled={saving}
/>
{#if baseUrl}
<Button
wrapperClasses="self-start"
href={`${baseUrl}/settings/admin/security`}
target="_blank"
startIcon={{ icon: ExternalLink }}
>
{serviceDisplayName} OAuth Settings
</Button>
{/if}
</div>
<div class="flex flex-col gap-1">
<Label label="Redirect URI" />
<p class="text-xs font-normal text-secondary"
>Use this URL when creating the OAuth app in {serviceDisplayName}</p
>
<div class="flex gap-2">
<input
type="url"
bind:value={redirectUri}
readonly
class="windmill-input flex-1 bg-gray-50 dark:bg-gray-900"
/>
<Button
size="xs"
color="light"
variant="border"
onclick={() => copyToClipboard(redirectUri, 'Redirect URI')}
>
Copy
</Button>
</div>
</div>
<div class="flex flex-col gap-1">
<Label label="Client ID" />
<input
type="text"
bind:value={clientId}
placeholder="Generated by Nextcloud OAuth app"
class="windmill-input"
disabled={saving}
/>
</div>
<div class="flex flex-col gap-1">
<Label label="Client secret" />
<input
type="password"
bind:value={clientSecret}
placeholder="Generated by Nextcloud OAuth app"
class="windmill-input"
disabled={saving}
/>
</div>
<div class="flex gap-2">
<Button
color="blue"
onclick={saveConfig}
disabled={saving || !clientId.trim() || !clientSecret.trim() || !baseUrl.trim()}
startIcon={{ icon: Save }}
>
{saving ? 'Saving...' : 'Save configuration'}
</Button>
</div>
</div>
</div>
{#if existingConfig}
<Alert type="success" title="OAuth client configured">
<p class="text-sm">
OAuth client is configured for {serviceDisplayName}. You can now connect this workspace to
your {serviceDisplayName} instance.
</p>
</Alert>
{/if}
</div>
@@ -0,0 +1,329 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/utils'
import { Button, Alert } from '$lib/components/common'
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import Description from '$lib/components/Description.svelte'
import { Check, X, ExternalLink, Cog, Plug } from 'lucide-svelte'
import { NextcloudIcon } from '$lib/components/icons'
import { WorkspaceIntegrationService, type NativeServiceName } from '$lib/gen'
import OAuthClientConfig from './OAuthClientConfig.svelte'
import { page } from '$app/stores'
import { goto } from '$app/navigation'
interface WorkspaceIntegration {
service_name: string
oauth_data: {
client_id: string
client_secret: string
base_url: string
redirect_uri: string
} | null
}
interface ServiceConfig {
name: string
displayName: string
description: string
icon: any
docsUrl?: string
}
const supportedServices: Record<string, ServiceConfig> = {
nextcloud: {
name: 'nextcloud',
displayName: 'Nextcloud',
description: 'Connect to Nextcloud for file operations and webhook triggers',
icon: NextcloudIcon,
docsUrl: 'https://www.windmill.dev/docs/integrations/nextcloud'
}
}
let integrations = $state<WorkspaceIntegration[]>([])
let loading = $state(false)
let connecting = $state<string | null>(null)
let showingConfig = $state<string | null>(null)
let processingCallback = $state(false)
async function loadIntegrations() {
if (!$workspaceStore) return
loading = true
try {
const response = await WorkspaceIntegrationService.listNativeTriggerServices({
workspace: $workspaceStore
})
integrations = response.map((item) => ({
service_name: item.service_name,
oauth_data: item.oauth_data || null
}))
} catch (err: any) {
console.error('Failed to load workspace integrations:', err)
sendUserToast(`Failed to load integrations: ${err.message}`, true)
} finally {
loading = false
}
}
async function deleteIntegration(serviceName: string) {
if (!$workspaceStore) return
try {
await WorkspaceIntegrationService.deleteNativeTriggerService({
workspace: $workspaceStore,
serviceName: serviceName as any
})
sendUserToast(`${supportedServices[serviceName]?.displayName} disconnected successfully`)
loadIntegrations()
} catch (err: any) {
sendUserToast(
`Failed to disconnect ${supportedServices[serviceName]?.displayName}: ${err.message}`,
true
)
}
}
async function connectService(serviceName: string, redirectUri: string) {
if (!$workspaceStore) return
connecting = serviceName
try {
const auth_url = await WorkspaceIntegrationService.generateNativeTriggerServiceConnectUrl({
workspace: $workspaceStore,
serviceName: serviceName as any,
requestBody: { redirect_uri: redirectUri }
})
if (auth_url) {
window.location.href = auth_url
}
} catch (err: any) {
sendUserToast(
`Failed to connect ${supportedServices[serviceName]?.displayName}: ${err.message}`,
true
)
connecting = null
}
}
async function createOrUpdateIntegration(serviceName: string, oauthData: any) {
if (!$workspaceStore) return
try {
await WorkspaceIntegrationService.createNativeTriggerService({
workspace: $workspaceStore,
serviceName: serviceName as any,
requestBody: oauthData
})
sendUserToast(
`${supportedServices[serviceName]?.displayName} configuration saved successfully`
)
loadIntegrations()
} catch (err: any) {
sendUserToast(
`Failed to configure ${supportedServices[serviceName]?.displayName}: ${err.message}`,
true
)
}
}
function isConfigured(integration: WorkspaceIntegration): boolean {
return (
integration.oauth_data !== null &&
!!integration.oauth_data.client_id &&
!!integration.oauth_data.client_secret &&
!!integration.oauth_data.base_url
)
}
function isConnected(integration: WorkspaceIntegration): boolean {
return isConfigured(integration) && !!(integration.oauth_data as any)?.access_token
}
function getIntegrationByService(serviceName: string): WorkspaceIntegration | null {
return integrations.find((integration) => integration.service_name === serviceName) || null
}
async function handleOAuthCallback(
workspace: string,
serviceName: NativeServiceName,
code: string,
state: string
) {
console.log({ workspace, serviceName, code, state })
processingCallback = true
try {
if (serviceName) {
await WorkspaceIntegrationService.nativeTriggerServiceCallback({
serviceName,
workspace,
code,
state,
requestBody: { redirect_uri: $page.url.toString() }
})
sendUserToast(`${supportedServices[serviceName]?.displayName} connected successfully!`)
await loadIntegrations()
} else {
sendUserToast(`Could not handle callback missing service query args`, true)
}
} catch (err: any) {
sendUserToast(`Failed to complete OAuth connection: ${err.message}`, true)
} finally {
const url = new URL($page.url)
url.searchParams.delete('code')
url.searchParams.delete('state')
url.searchParams.delete('service')
url.searchParams.delete('workspace')
processingCallback = false
goto(url.toString(), { replaceState: true, noScroll: true, keepFocus: true })
}
}
$effect(() => {
if (
$page.url.searchParams.has('code') &&
$page.url.searchParams.has('state') &&
$page.url.searchParams.has('workspace') &&
$page.url.searchParams.has('service')
) {
const service = $page.url.searchParams.get('service')! as NativeServiceName
const workspace = $page.url.searchParams.get('workspace')!
const code = $page.url.searchParams.get('code')!
const state = $page.url.searchParams.get('state')!
handleOAuthCallback(workspace, service, code, state)
}
})
let redirectUri = $state('')
$effect(() => {
if ($workspaceStore) {
loadIntegrations()
}
})
</script>
<div class="flex flex-col gap-6 my-8">
<div class="flex flex-col gap-1">
<div class="text-sm font-semibold text-emphasis">Native Triggers</div>
<Description>
Connect your workspace to external services for native triggers and enhanced functionality.
These connections are shared across all workspace members and are required for native triggers
to work.
</Description>
<Description link="https://www.windmill.dev/docs/integrations/native-triggers">
Learn more about native triggers and workspace integrations.
</Description>
</div>
{#if processingCallback}
<Alert type="info" title="Processing OAuth connection">
<p class="text-sm">Completing your OAuth connection, please wait...</p>
</Alert>
{:else if loading}
<div class="space-y-4">
{#each new Array(3) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
</div>
{:else}
<div class="space-y-4">
{#each Object.entries(supportedServices) as [serviceName, config]}
{@const integration = getIntegrationByService(serviceName)}
{@const isConnecting = connecting === serviceName}
{@const isOAuthConfigured = integration && isConfigured(integration)}
{@const isServiceConnected = integration && isConnected(integration)}
{@const isShowingConfig = showingConfig === serviceName}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-surface">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 flex items-center justify-center">
<config.icon class="w-6 h-6" />
</div>
<div class="flex flex-col">
<div class="text-sm font-semibold text-emphasis">{config.displayName}</div>
<div class="text-xs font-normal text-primary">{config.description}</div>
</div>
</div>
<div class="flex items-center gap-2">
{#if isServiceConnected}
<div class="flex items-center gap-1 text-green-600 text-xs">
<Check size={16} />
<span class="font-semibold">Connected</span>
</div>
<Button
onclick={() => connectService(serviceName, redirectUri)}
disabled={isConnecting}
startIcon={{ icon: Plug }}
>
{isConnecting ? 'Reconnecting...' : 'Reconnect'}
</Button>
<Button
destructive
onclick={() => deleteIntegration(serviceName)}
startIcon={{ icon: X }}
>
Delete
</Button>
{:else if isOAuthConfigured}
<Button
variant="accent"
onclick={() => connectService(serviceName, redirectUri)}
disabled={isConnecting}
startIcon={{ icon: Plug }}
>
{isConnecting ? 'Connecting...' : 'Connect'}
</Button>
<Button
destructive
onclick={() => deleteIntegration(serviceName)}
startIcon={{ icon: X }}
>
Delete
</Button>
{:else}
<Button
variant="accent"
onclick={() =>
(showingConfig = showingConfig === serviceName ? null : serviceName)}
startIcon={{ icon: Cog }}
>
Configure OAuth
</Button>
{/if}
{#if config.docsUrl}
<Button href={config.docsUrl} target="_blank" startIcon={{ icon: ExternalLink }}>
Docs
</Button>
{/if}
</div>
</div>
{#if isShowingConfig}
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<OAuthClientConfig
{serviceName}
bind:redirectUri
serviceDisplayName={config.displayName}
existingConfig={integration?.oauth_data}
onConfigSaved={async (oauthData) => {
await createOrUpdateIntegration(serviceName, oauthData)
showingConfig = null
}}
/>
</div>
{/if}
</div>
{/each}
</div>
{#if integrations.length === 0}
<Alert type="warning" title="No Integrations Connected">
Connect to external services above to enable native triggers for your workspace.
</Alert>
{/if}
{/if}
</div>
@@ -220,7 +220,8 @@
sqs_used,
mqtt_used,
gcp_used,
email_used
email_used,
nextcloud_used
} = await WorkspaceService.getUsedTriggers({
workspace: $workspaceStore ?? ''
})
@@ -251,6 +252,9 @@
if (email_used) {
usedKinds.push('email')
}
if (nextcloud_used) {
usedKinds.push('nextcloud')
}
$usedTriggerKinds = usedKinds
}
@@ -0,0 +1,292 @@
<script lang="ts">
import { NativeTriggerService } from '$lib/gen'
import type { NativeServiceName } from '$lib/gen'
import type { ExtendedNativeTrigger } from '$lib/components/triggers/native/utils'
import {
isServiceAvailable,
formatTriggerDisplayName,
getServiceConfig
} from '$lib/components/triggers/native/utils'
import NativeTriggerTable from '$lib/components/triggers/native/NativeTriggerTable.svelte'
import NativeTriggerEditor from '$lib/components/triggers/native/NativeTriggerEditor.svelte'
import {
sendUserToast,
removeTriggerKindIfUnused,
getLocalSetting,
storeLocalSetting
} from '$lib/utils'
import { userStore, workspaceStore, userWorkspaces, usedTriggerKinds } from '$lib/stores'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import { Button, Alert, Skeleton } from '$lib/components/common'
import { LoaderCircle, Plus } from 'lucide-svelte'
import SearchItems from '$lib/components/SearchItems.svelte'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import { page } from '$app/state'
import Toggle from '$lib/components/Toggle.svelte'
import ListFilters from '$lib/components/home/ListFilters.svelte'
type TriggerW = ExtendedNativeTrigger & { marked?: any }
const serviceName = $derived(page.params.service_name as NativeServiceName)
const serviceConfig = $derived(getServiceConfig(serviceName))
let triggers: TriggerW[] = $state([])
let loading = $state(true)
let serviceAvailable: boolean | undefined = $state(undefined)
let serviceSupported = $state(true)
let editor: NativeTriggerEditor
let filteredItems: TriggerW[] = $state([])
let items: TriggerW[] = $state([])
let preFilteredItems: TriggerW[] = $state([])
let filter = $state('')
let ownerFilter: string | undefined = $state(undefined)
let nbDisplayed = $state(15)
const FILTER_USER_FOLDER_SETTING_NAME = 'native_trigger_user_and_folders_only'
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
$effect(() => {
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
})
function filterItemsPathsBaseOnUserFilters(item: TriggerW, filterUserFolders: boolean) {
if ($workspaceStore == 'admins') return true
if (filterUserFolders) {
return (
!item.script_path.startsWith('u/') ||
item.script_path.startsWith('u/' + $userStore?.username + '/')
)
} else {
return true
}
}
$effect(() => {
preFilteredItems =
ownerFilter != undefined
? triggers?.filter(
(x) =>
x.script_path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, filterUserFolders)
)
: triggers?.filter((x) => filterItemsPathsBaseOnUserFilters(x, filterUserFolders))
})
$effect(() => {
if ($workspaceStore) {
ownerFilter = undefined
}
})
let owners = $derived(
Array.from(
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
)
$effect(() => {
items = filter !== '' ? filteredItems : preFilteredItems
})
async function checkServiceAvailability() {
if (!serviceConfig) {
serviceSupported = false
return
}
if ($workspaceStore) {
serviceAvailable = await isServiceAvailable(serviceName, $workspaceStore)
if (!serviceAvailable) {
sendUserToast(
`${serviceName} integration not available, set it in the workspace settings`,
true
)
}
}
}
async function loadTriggers() {
if (!$workspaceStore || !serviceAvailable) return
loading = true
try {
const triggerList = await NativeTriggerService.listNativeTriggers({
workspace: $workspaceStore,
serviceName: serviceName
})
triggers = triggerList as TriggerW[]
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, serviceName, $usedTriggerKinds)
} catch (err: any) {
console.error('Failed to load triggers:', err)
sendUserToast(`Failed to load triggers: ${err.body || err.message}`, true)
} finally {
loading = false
}
}
async function syncTriggers() {
if (!serviceConfig?.supportsSync) {
sendUserToast('Sync is not supported for this service')
return
}
if (!serviceAvailable) {
sendUserToast('Cannot sync triggers: workspace integration not connected', true)
return
}
try {
await NativeTriggerService.syncNativeTriggers({
workspace: $workspaceStore!,
serviceName: serviceName
})
sendUserToast(`Successfully synced ${serviceConfig.serviceDisplayName} triggers`)
loadTriggers()
} catch (err: any) {
const errorMessage = err.body || err.message
if (
errorMessage.includes('workspace integration') ||
errorMessage.includes('not connected')
) {
sendUserToast(
`Cannot sync: ${serviceConfig.serviceDisplayName} workspace integration not connected. Please connect it in workspace settings.`,
true
)
} else {
sendUserToast(`Failed to sync triggers: ${errorMessage}`, true)
}
}
}
checkServiceAvailability().then(() => {
if (serviceAvailable) {
loadTriggers()
}
})
</script>
<NativeTriggerEditor bind:this={editor} service={serviceName} onUpdate={loadTriggers} />
<SearchItems
{filter}
items={preFilteredItems}
bind:filteredItems
f={(trigger) => `${formatTriggerDisplayName(trigger)}`}
/>
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{:else if !serviceSupported}
<CenteredPage>
<div class="max-w-md mx-auto text-center py-12">
<h2 class="text-lg font-semibold text-emphasis mb-2">Service not supported</h2>
<p class="text-secondary text-xs mb-4">
The service "{serviceName}" is not supported for native triggers.
</p>
<p class="text-xs text-secondary">Supported services: nextcloud</p>
</div>
</CenteredPage>
{:else}
<CenteredPage>
<PageHeader
title="{serviceConfig?.serviceDisplayName || serviceName} triggers"
tooltip="Native triggers managed externally by {serviceConfig?.serviceDisplayName ||
serviceName}. These are more efficient than regular triggers as they're handled directly by the service provider."
>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => editor?.openNew()}
disabled={!serviceAvailable}
>
New&nbsp;{serviceConfig?.serviceDisplayName || serviceName} trigger
</Button>
</PageHeader>
{#if serviceAvailable === false}
<Alert
title="{serviceConfig?.serviceDisplayName || serviceName} triggers are not available"
type="warning"
>
<div class="flex flex-col gap-2 mt-2">
The workspace doesn't have a {serviceConfig?.serviceDisplayName || serviceName} integration
connected
<div class="flex gap-2">
<Button variant="accent" href="/workspace_settings?tab=native_triggers">
Manage native triggers
</Button>
<Button variant="subtle" onclick={() => checkServiceAvailability()}>Retry check</Button>
</div>
</div>
</Alert>
{:else if serviceAvailable}
<div class="w-full h-full flex flex-col">
<div class="w-full pb-4 pt-6">
<input
type="text"
placeholder="Search {serviceConfig?.serviceDisplayName || serviceName} triggers"
bind:value={filter}
class="search-item mb-2"
/>
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
<div class="flex flex-row items-center justify-end gap-4">
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
<Toggle
size="xs"
bind:checked={filterUserFolders}
options={{ right: `Only u/${$userStore.username} and f/*` }}
/>
{/if}
</div>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !triggers?.length}
<div class="text-center text-sm font-semibold text-emphasis mt-2">
No {serviceConfig?.serviceDisplayName || serviceName} triggers
</div>
{:else if items?.length}
<NativeTriggerTable
service={serviceName}
triggers={items.slice(0, nbDisplayed)}
{loading}
onEdit={(trigger) => editor?.openEdit(trigger.external_id, trigger.is_flow)}
onRecreate={(trigger) => editor?.openRecreate(trigger)}
onSync={syncTriggers}
/>
{:else}
<NoItemFound />
{/if}
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
<span class="text-xs font-normal text-primary"
>{nbDisplayed} items out of {items.length}
<button class="ml-4 font-semibold text-emphasis" onclick={() => (nbDisplayed += 30)}
>load 30 more</button
></span
>
{/if}
{:else}
<div class="flex flex-col items-center justify-center h-64 gap-3">
<LoaderCircle class="animate-spin text-accent-primary" size={32} />
<div class="text-secondary text-xs"
>Checking {serviceConfig?.serviceDisplayName || serviceName} availability...</div
>
</div>
{/if}
</CenteredPage>
{/if}
@@ -0,0 +1,22 @@
import { getServiceConfig } from '$lib/components/triggers/native/utils'
import { error } from '@sveltejs/kit'
import type { PageLoad } from './$types'
import type { NativeServiceName } from '$lib/gen'
export const load: PageLoad = async ({ params }) => {
const serviceName = params.service_name as NativeServiceName
const serviceConfig = getServiceConfig(serviceName)
if (!serviceConfig) {
throw error(404, {
message: `Service "${serviceName}" is not supported for native triggers.`
})
}
return {
serviceName,
serviceConfig,
stuff: { title: `${serviceConfig.serviceDisplayName} triggers` }
}
}
@@ -156,6 +156,7 @@
| 'windmill_lfs'
| 'git_sync'
| 'default_app'
| 'native_triggers'
| 'encryption'
| 'dependencies'
// Both 'slack' and 'teams' URLs map to 'slack' tab
@@ -753,6 +754,13 @@
aiDescription="Data tables workspace settings"
label="Data Tables"
/>
<Tab
small
value="native_triggers"
aiId="workspace-settings-integrations"
aiDescription="Workspace integrations for native triggers"
label="Native Triggers"
/>
<Tab
small
value="windmill_lfs"
@@ -1397,6 +1405,16 @@ export async function main(
/>
{/key}
</div>
{:else if tab == 'native_triggers'}
{#if $workspaceStore}
{#await import('$lib/components/workspaceSettings/WorkspaceIntegrations.svelte') then { default: WorkspaceIntegrations }}
<WorkspaceIntegrations />
{/await}
{:else}
<div class="flex items-center justify-center p-8">
<div class="text-sm text-secondary">Loading workspace...</div>
</div>
{/if}
{:else if tab == 'encryption'}
<div class="flex flex-col gap-4 my-8">
<div class="flex flex-col gap-1">