mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-23 00:00:33 +00:00
Merge branch 'main' into alp/gh_app_timeout
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
+2
-1
@@ -29,7 +29,8 @@
|
||||
"postgres",
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt"
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -121,7 +121,8 @@
|
||||
"postgres",
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt"
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -39,7 +39,8 @@
|
||||
"postgres",
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt"
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -39,7 +39,8 @@
|
||||
"sqs",
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email"
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -23,7 +23,8 @@
|
||||
"sqs",
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email"
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -31,7 +31,8 @@
|
||||
"sqs",
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email"
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -68,7 +69,8 @@
|
||||
"sqs",
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email"
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -244,7 +244,8 @@
|
||||
"postgres",
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt"
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@
|
||||
"sqs",
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email"
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -39,7 +39,8 @@
|
||||
"postgres",
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt"
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -26,7 +26,8 @@
|
||||
"sqs",
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email"
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -34,7 +34,8 @@
|
||||
"sqs",
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email"
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -31,7 +31,8 @@
|
||||
"sqs",
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email"
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -29,7 +29,8 @@
|
||||
"postgres",
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt"
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -154,7 +154,8 @@
|
||||
"postgres",
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt"
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -184,7 +184,8 @@
|
||||
"postgres",
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt"
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@
|
||||
"sqs",
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email"
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -104,7 +104,8 @@
|
||||
"postgres",
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt"
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -159,7 +159,8 @@
|
||||
"postgres",
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt"
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -23,7 +23,8 @@
|
||||
"sqs",
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email"
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
"sqs",
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email"
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -23,7 +23,8 @@
|
||||
"sqs",
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email"
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -184,7 +184,8 @@
|
||||
"postgres",
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt"
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -23,7 +23,8 @@
|
||||
"sqs",
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email"
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -104,7 +104,8 @@
|
||||
"postgres",
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt"
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1
@@ -15555,6 +15555,7 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
"sql-builder",
|
||||
"sqlx",
|
||||
"strum 0.27.2",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
+138
@@ -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)
|
||||
]
|
||||
: [])
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {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">
|
||||
|
||||
Reference in New Issue
Block a user