diff --git a/.agents/skills/commit/SKILL.md b/.agents/skills/commit/SKILL.md new file mode 100644 index 0000000000..d610fa3f65 --- /dev/null +++ b/.agents/skills/commit/SKILL.md @@ -0,0 +1,59 @@ +--- +name: commit +description: Create a git commit with conventional commit format. MUST use anytime you want to commit changes. +--- + +# Git Commit Skill + +Create a focused, single-line commit following conventional commit conventions. + +## Instructions + +1. **Analyze changes**: Run `git status` and `git diff` to understand what was modified +2. **Stage only modified files**: Add files individually by name. NEVER use `git add -A` or `git add .` +3. **Write commit message**: Follow the conventional commit format as a single line + +## Conventional Commit Format + +``` +: +``` + +### Types +- `feat`: New feature or capability +- `fix`: Bug fix +- `refactor`: Code change that neither fixes a bug nor adds a feature +- `docs`: Documentation only changes +- `style`: Formatting, missing semicolons, etc (no code change) +- `test`: Adding or correcting tests +- `chore`: Maintenance tasks, dependency updates, etc +- `perf`: Performance improvement + +### Rules +- Message MUST be a single line (no multi-line messages) +- Description should be lowercase, imperative mood ("add" not "added") +- No period at the end +- Keep under 72 characters total + +### Examples +``` +feat: add token usage tracking for AI providers +fix: resolve null pointer in job executor +refactor: extract common validation logic +docs: update API endpoint documentation +chore: upgrade sqlx to 0.7 +``` + +## Execution Steps + +1. Run `git status` to see all changes +2. Run `git diff` to understand the changes in detail +3. Run `git log --oneline -5` to see recent commit style +4. Stage ONLY the modified/relevant files: `git add ...` +5. Create the commit with conventional format: + ```bash + git commit -m ": + + Co-Authored-By: Claude Opus 4.5 " + ``` +6. Run `git status` to verify the commit succeeded diff --git a/.agents/skills/local-review/SKILL.md b/.agents/skills/local-review/SKILL.md new file mode 100644 index 0000000000..ad701ac367 --- /dev/null +++ b/.agents/skills/local-review/SKILL.md @@ -0,0 +1,97 @@ +--- +name: local-review +description: Code review a pull request for bugs and CLAUDE.md compliance. MUST use when asked to review code. +--- + +# Local Code Review Skill + +Review a pull request for real bugs and CLAUDE.md compliance violations. This review targets HIGH SIGNAL issues only. + +## Review Philosophy + +- **Only flag issues you are certain about.** If you are not sure an issue is real, do not flag it. False positives erode trust and waste reviewer time. +- Think like a senior engineer doing a final review — flag things that would cause incidents, not things that are merely imperfect. + +## What to Flag + +- Code that won't compile or parse (syntax errors, type errors, missing imports) +- Code that will definitely produce wrong results regardless of inputs +- Clear, unambiguous CLAUDE.md violations (quote the exact rule being violated) +- Security issues in introduced code (injection, auth bypass, data exposure) +- Incorrect logic that will fail in production + +## What NOT to Flag + +- Code style or quality concerns +- Potential issues that depend on specific inputs or runtime state +- Subjective suggestions or improvements +- Pre-existing issues not introduced by this PR +- Pedantic nitpicks a senior engineer wouldn't flag +- Issues a linter or type checker will catch +- General quality concerns unless explicitly prohibited in CLAUDE.md +- Issues silenced via lint ignore comments + +## Execution Steps + +1. **Determine the PR scope**: + - If an argument is provided, use it as the PR number or branch + - Otherwise, detect from the current branch vs main + - Run `gh pr view` if a PR exists, or use `git diff main...HEAD` + +2. **Find relevant CLAUDE.md files**: + - Read the root `CLAUDE.md` + - Check for CLAUDE.md files in directories containing changed files + +3. **Get the diff and metadata**: + - `gh pr diff` or `git diff main...HEAD` for the full diff + - `gh pr view` or `git log main..HEAD --oneline` for context + +4. **Read changed files** where the diff alone is insufficient to understand context + +5. **Review for**: + - CLAUDE.md compliance — check each rule against the changed code + - Bugs and logic errors — will this code work correctly? + - Security issues — injection, auth, data exposure in new code + +6. **Self-validate each finding**: Before reporting, ask yourself: + - "Is this definitely a real issue, not a false positive?" + - "Would a senior engineer flag this in review?" + - If the answer to either is no, discard the finding + +7. **Output findings** to the terminal (default) or post as PR comments (with `--comment` flag) + +## Output Format + +``` +## Code review + +Found N issues: + +1. () + + +2. () + +``` + +If no issues are found: + +``` +## Code review + +No issues found. Checked for bugs and CLAUDE.md compliance. +``` + +## Posting Comments (--comment flag) + +If the user passes `--comment`, post findings as inline PR comments using: + +```bash +gh pr review --comment --body "" +``` + +Or for inline comments on specific lines: + +```bash +gh api repos/{owner}/{repo}/pulls/{pr}/reviews -f body="" -f event="COMMENT" -f comments="[...]" +``` diff --git a/.agents/skills/native-trigger/SKILL.md b/.agents/skills/native-trigger/SKILL.md new file mode 100644 index 0000000000..026c1900bf --- /dev/null +++ b/.agents/skills/native-trigger/SKILL.md @@ -0,0 +1,777 @@ +# Skill: Adding Native Trigger Services + +This skill provides comprehensive guidance for adding new native trigger services to Windmill. Native triggers allow external services (like Nextcloud, Google Drive, etc.) to trigger Windmill scripts/flows via webhooks or push notifications. + +## Architecture Overview + +The native trigger system consists of: + +1. **Database Layer** - PostgreSQL tables and enum types +2. **Backend Rust Implementation** - Core trait, handlers, and service modules in the `windmill-native-triggers` crate +3. **Frontend Svelte Components** - Configuration forms and UI components + +### Key Files + +| Component | Path | +|-----------|------| +| Core module with `External` trait | `backend/windmill-native-triggers/src/lib.rs` | +| Generic CRUD handlers | `backend/windmill-native-triggers/src/handler.rs` | +| Background sync logic | `backend/windmill-native-triggers/src/sync.rs` | +| OAuth/workspace integration | `backend/windmill-native-triggers/src/workspace_integrations.rs` | +| Re-export shim (windmill-api) | `backend/windmill-api/src/native_triggers/mod.rs` | +| TriggerKind enum | `backend/windmill-common/src/triggers.rs` | +| JobTriggerKind enum | `backend/windmill-common/src/jobs.rs` | +| Frontend service registry | `frontend/src/lib/components/triggers/native/utils.ts` | +| Frontend trigger utilities | `frontend/src/lib/components/triggers/utils.ts` | +| Trigger badges (icons + counts) | `frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte` | +| Workspace integrations UI | `frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte` | +| OAuth config form component | `frontend/src/lib/components/workspaceSettings/OAuthClientConfig.svelte` | +| OpenAPI spec | `backend/windmill-api/openapi.yaml` | +| Reference: Nextcloud module | `backend/windmill-native-triggers/src/nextcloud/` | +| Reference: Google module | `backend/windmill-native-triggers/src/google/` | + +### Crate Structure + +The native trigger code lives in the `windmill-native-triggers` crate (`backend/windmill-native-triggers/`). The `windmill-api` crate re-exports everything via a shim: + +```rust +// backend/windmill-api/src/native_triggers/mod.rs +pub use windmill_native_triggers::*; +``` + +All new service modules go in `backend/windmill-native-triggers/src/`. + +--- + +## Core Concepts + +### The `External` Trait + +Every native trigger service implements the `External` trait defined in `lib.rs`: + +```rust +#[async_trait] +pub trait External: Send + Sync + 'static { + // Associated types: + type ServiceConfig: Debug + DeserializeOwned + Serialize + Send + Sync; + type TriggerData: Debug + Serialize + Send + Sync; + type OAuthData: DeserializeOwned + Serialize + Clone + Send + Sync; + type CreateResponse: DeserializeOwned + Send + Sync; + + // Constants: + const SUPPORT_WEBHOOK: bool; + const SERVICE_NAME: ServiceName; + const DISPLAY_NAME: &'static str; + const TOKEN_ENDPOINT: &'static str; + const REFRESH_ENDPOINT: &'static str; + const AUTH_ENDPOINT: &'static str; + + // Required methods: + async fn create(&self, w_id, oauth_data, webhook_token, data, db, tx) -> Result; + async fn update(&self, w_id, oauth_data, external_id, webhook_token, data, db, tx) -> Result; + async fn get(&self, w_id, oauth_data, external_id, db, tx) -> Result; + async fn delete(&self, w_id, oauth_data, external_id, db, tx) -> Result<()>; + async fn exists(&self, w_id, oauth_data, external_id, db, tx) -> Result; + async fn maintain_triggers(&self, db, workspace_id, triggers, oauth_data, synced, errors); + fn external_id_and_metadata_from_response(&self, resp) -> (String, Option); + + // Methods with defaults: + async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result; + fn service_config_from_create_response(&self, data, resp) -> Option; + fn additional_routes(&self) -> axum::Router; + async fn http_client_request(&self, url, method, workspace_id, tx, db, headers, body) -> Result; +} +``` + +Key design points: +- **`update()` returns `serde_json::Value`** - the resolved service_config to store. Each service is responsible for building the final config. +- **`maintain_triggers()`** - periodic background maintenance. Each service implements its own strategy (Nextcloud: reconcile with external state; Google: renew expiring channels). +- **No `list_all()` in the trait** - services that need it (Nextcloud) implement it privately; services that don't (Google) use different maintenance strategies. +- **No `get_external_id_from_trigger_data()` or `extract_service_config_from_trigger_data()`** - removed in favor of the `maintain_triggers` pattern. + +### Create Lifecycle: Two Paths + +The `create_native_trigger` handler in `handler.rs` supports two creation flows, controlled by `service_config_from_create_response()`: + +**Path A: Short (Google pattern)** - `service_config_from_create_response()` returns `Some(config)`: +1. `create()` registers on external service +2. `external_id_and_metadata_from_response()` extracts the ID +3. `service_config_from_create_response()` builds the config directly from input data + response metadata +4. Stores trigger in DB -- done, no extra round-trip + +Use this when the external_id is known before the create call (e.g., Google generates the channel_id as a UUID upfront and includes it in the webhook URL). + +**Path B: Long (Nextcloud pattern)** - `service_config_from_create_response()` returns `None` (default): +1. `create()` registers on external service (webhook URL has no external_id yet) +2. `external_id_and_metadata_from_response()` extracts the ID +3. `update()` is called to fix the webhook URL with the now-known external_id +4. `update()` returns the resolved service_config +5. Stores trigger in DB + +Use this when the external_id is assigned by the remote service and the webhook URL needs to be corrected after creation. + +### OAuth Token Storage (Three-Table Pattern) + +OAuth tokens are stored across three tables, NOT in `workspace_integrations.oauth_data` directly: + +| Table | What's Stored | +|-------|---------------| +| `workspace_integrations` | `oauth_data` JSON with `base_url`, `client_id`, `client_secret`, `instance_shared` flag; `resource_path` pointing to the variable | +| `variable` | Encrypted `access_token` (at the path stored in `resource_path`), linked to `account` via `account` column | +| `account` | `refresh_token`, keyed by `workspace_id` + `client` (service name) + `is_workspace_integration = true` | + +The `decrypt_oauth_data()` function in `lib.rs` assembles these into a unified struct: +```rust +pub struct OAuthConfig { + pub base_url: String, + pub access_token: String, // decrypted from variable + pub refresh_token: Option, // from account table + pub client_id: String, // from oauth_data or instance settings + pub client_secret: String, // from oauth_data or instance settings +} +``` + +Instance-level sharing: when `oauth_data.instance_shared == true`, `client_id` and `client_secret` are read from global settings instead of workspace_integrations. + +### URL Resolution + +The `resolve_endpoint()` helper handles both absolute and relative OAuth URLs: + +```rust +pub fn resolve_endpoint(base_url: &str, endpoint: &str) -> String { + if endpoint.starts_with("http://") || endpoint.starts_with("https://") { + endpoint.to_string() // Google: absolute URLs + } else { + format!("{}{}", base_url, endpoint) // Nextcloud: relative paths + } +} +``` + +### ServiceName Methods + +`ServiceName` is the central registry enum. Each variant must implement these match arms: + +| Method | Purpose | +|--------|---------| +| `as_str()` | Lowercase identifier (e.g., `"google"`) | +| `as_trigger_kind()` | Maps to `TriggerKind` enum | +| `as_job_trigger_kind()` | Maps to `JobTriggerKind` enum | +| `token_endpoint()` | OAuth token endpoint (relative or absolute) | +| `auth_endpoint()` | OAuth authorization endpoint | +| `oauth_scopes()` | Space-separated OAuth scopes | +| `resource_type()` | Resource type for token storage (e.g., `"gworkspace"`) | +| `extra_auth_params()` | Extra OAuth params (e.g., Google needs `access_type=offline`, `prompt=consent`) | +| `integration_service()` | Maps to the workspace integration service (usually `*self`) | +| `TryFrom` | Parse from string | +| `Display` | Delegates to `as_str()` | + +--- + +## Step-by-Step Implementation Guide + +### Step 1: Database Migration + +Create a new migration file: `backend/migrations/YYYYMMDDHHMMSS_newservice_trigger.up.sql` + +```sql +-- Add the service to the native_trigger_service enum +ALTER TYPE native_trigger_service ADD VALUE IF NOT EXISTS 'newservice'; + +-- Add to TRIGGER_KIND enum (used for trigger tracking) +ALTER TYPE TRIGGER_KIND ADD VALUE IF NOT EXISTS 'newservice'; + +-- Add to job_trigger_kind enum (used for job tracking) +ALTER TYPE job_trigger_kind ADD VALUE IF NOT EXISTS 'newservice'; +``` + +Also create the corresponding down migration. + +### Step 2: Update windmill-common Enums + +#### `backend/windmill-common/src/triggers.rs` + +Add variant to `TriggerKind` enum, and update `to_key()` and `fmt()` implementations. + +#### `backend/windmill-common/src/jobs.rs` + +Add variant to `JobTriggerKind` enum and update the `Display` implementation. + +### Step 3: Backend Service Module + +Create a new directory: `backend/windmill-native-triggers/src/newservice/` + +#### `mod.rs` - Type Definitions + +```rust +use serde::{Deserialize, Serialize}; + +pub mod external; +// pub mod routes; // Only if you need additional service-specific routes + +/// OAuth data deserialized from the three-table pattern. +/// The actual structure is built by decrypt_oauth_data() from variable + account + workspace_integrations. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct NewServiceOAuthData { + pub base_url: String, // from workspace_integrations.oauth_data + pub access_token: String, // decrypted from variable table + pub refresh_token: Option, // from account table + // Note: client_id and client_secret are in OAuthConfig, not here + // unless the service needs them at runtime for API calls +} + +/// Configuration provided by user when creating/updating a trigger. +/// Stored as JSON in native_trigger.service_config. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NewServiceConfig { + // Service-specific configuration fields + pub folder_path: String, + pub file_filter: Option, +} + +/// Data retrieved from the external service about a trigger. +/// Returned by the get() method and shown in the UI. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NewServiceTriggerData { + pub folder_path: String, + pub file_filter: Option, + // Fields that shouldn't affect service_config comparison should use #[serde(skip_serializing)] +} + +/// Response from external service when creating a trigger/webhook. +#[derive(Debug, Deserialize)] +pub struct CreateTriggerResponse { + pub id: String, +} + +/// Handler struct (stateless, used for routing) +#[derive(Copy, Clone)] +pub struct NewService; +``` + +#### `external.rs` - External Trait Implementation + +```rust +use async_trait::async_trait; +use reqwest::Method; +use sqlx::PgConnection; +use std::collections::HashMap; +use windmill_common::{ + error::{Error, Result}, + BASE_URL, DB, +}; + +use crate::{ + generate_webhook_service_url, External, NativeTrigger, NativeTriggerData, ServiceName, + sync::{SyncError, TriggerSyncInfo}, +}; +use super::{NewService, NewServiceConfig, NewServiceOAuthData, NewServiceTriggerData, CreateTriggerResponse}; + +#[async_trait] +impl External for NewService { + type ServiceConfig = NewServiceConfig; + type TriggerData = NewServiceTriggerData; + type OAuthData = NewServiceOAuthData; + type CreateResponse = CreateTriggerResponse; + + const SERVICE_NAME: ServiceName = ServiceName::NewService; + const DISPLAY_NAME: &'static str = "New Service"; + const SUPPORT_WEBHOOK: bool = true; + const TOKEN_ENDPOINT: &'static str = "/oauth/token"; + const REFRESH_ENDPOINT: &'static str = "/oauth/token"; + const AUTH_ENDPOINT: &'static str = "/oauth/authorize"; + + async fn create( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + webhook_token: &str, + data: &NativeTriggerData, + db: &DB, + tx: &mut PgConnection, + ) -> Result { + let base_url = &*BASE_URL.read().await; + + // external_id is None during create (we get it from the response) + let webhook_url = generate_webhook_service_url( + base_url, w_id, &data.script_path, data.is_flow, + None, Self::SERVICE_NAME, webhook_token, + ); + + let url = format!("{}/api/webhooks/create", oauth_data.base_url); + let payload = serde_json::json!({ + "callback_url": webhook_url, + "folder_path": data.service_config.folder_path, + }); + + let response: CreateTriggerResponse = self + .http_client_request(&url, Method::POST, w_id, tx, db, None, Some(&payload)) + .await?; + + Ok(response) + } + + /// Update returns the resolved service_config as JSON. + /// For services using the update+get pattern, call self.get() and serialize. + async fn update( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + external_id: &str, + webhook_token: &str, + data: &NativeTriggerData, + db: &DB, + tx: &mut PgConnection, + ) -> Result { + let base_url = &*BASE_URL.read().await; + + let webhook_url = generate_webhook_service_url( + base_url, w_id, &data.script_path, data.is_flow, + Some(external_id), Self::SERVICE_NAME, webhook_token, + ); + + let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id); + let payload = serde_json::json!({ + "callback_url": webhook_url, + "folder_path": data.service_config.folder_path, + }); + + let _: serde_json::Value = self + .http_client_request(&url, Method::PUT, w_id, tx, db, None, Some(&payload)) + .await?; + + // Fetch back the updated state to get the resolved config + let trigger_data = self.get(w_id, oauth_data, external_id, db, tx).await?; + serde_json::to_value(&trigger_data) + .map_err(|e| Error::InternalErr(format!("Failed to serialize trigger data: {}", e))) + } + + async fn get( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + external_id: &str, + db: &DB, + tx: &mut PgConnection, + ) -> Result { + let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id); + self.http_client_request::<_, ()>(&url, Method::GET, w_id, tx, db, None, None).await + } + + async fn delete( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + external_id: &str, + db: &DB, + tx: &mut PgConnection, + ) -> Result<()> { + let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id); + let _: serde_json::Value = self + .http_client_request::<_, ()>(&url, Method::DELETE, w_id, tx, db, None, 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 { + match self.get(w_id, oauth_data, external_id, db, tx).await { + Ok(_) => Ok(true), + Err(Error::NotFound(_)) => Ok(false), + Err(e) => Err(e), + } + } + + /// Background maintenance. Choose the right pattern for your service: + /// - For services with queryable external state: use reconcile_with_external_state() + /// - For channel-based services with expiration: implement renewal logic + async fn maintain_triggers( + &self, + db: &DB, + workspace_id: &str, + triggers: &[NativeTrigger], + oauth_data: &Self::OAuthData, + synced: &mut Vec, + errors: &mut Vec, + ) { + // Option A: Reconcile with external state (Nextcloud pattern) + // Fetch all triggers from external service and compare with DB + let external_triggers = match self.list_all(workspace_id, oauth_data, db).await { + Ok(triggers) => triggers, + Err(e) => { + errors.push(SyncError { + resource_path: format!("workspace:{}", workspace_id), + error_message: format!("Failed to list triggers: {}", e), + error_type: "api_error".to_string(), + }); + return; + } + }; + + // Convert to (external_id, config_json) pairs + let external_pairs: Vec<(String, serde_json::Value)> = external_triggers + .into_iter() + .map(|t| (t.id.clone(), serde_json::to_value(&t).unwrap_or_default())) + .collect(); + + crate::sync::reconcile_with_external_state( + db, workspace_id, Self::SERVICE_NAME, triggers, &external_pairs, synced, errors, + ).await; + } + + fn external_id_and_metadata_from_response( + &self, + resp: &Self::CreateResponse, + ) -> (String, Option) { + (resp.id.clone(), None) + } + + // service_config_from_create_response: NOT overridden (returns None). + // This means the handler uses the update+get pattern after create. + // Override and return Some(...) to skip the update+get cycle (Google pattern). +} + +impl NewService { + /// Private helper to list all triggers from the external service. + async fn list_all( + &self, + w_id: &str, + oauth_data: &::OAuthData, + db: &DB, + ) -> Result::TriggerData>> { + // Implementation depends on the external service's API + todo!() + } +} +``` + +### Step 4: Update lib.rs Registry + +In `backend/windmill-native-triggers/src/lib.rs`: + +```rust +// Service modules - add new services here: +#[cfg(feature = "native_trigger")] +pub mod newservice; // <-- Add this + +// ServiceName enum - add variant: +pub enum ServiceName { + Nextcloud, + Google, + NewService, // <-- Add this +} + +// Then add match arms in ALL ServiceName methods: +// as_str(), as_trigger_kind(), as_job_trigger_kind(), token_endpoint(), +// auth_endpoint(), oauth_scopes(), resource_type(), extra_auth_params(), +// integration_service(), TryFrom, Display +``` + +### Step 5: Update handler.rs Routes + +In `backend/windmill-native-triggers/src/handler.rs`: + +```rust +pub fn generate_native_trigger_routers() -> Router { + // ... + #[cfg(feature = "native_trigger")] + { + use crate::newservice::NewService; + return router + .nest("/nextcloud", service_routes(NextCloud)) + .nest("/google", service_routes(Google)) + .nest("/newservice", service_routes(NewService)); // <-- Add this + } + // ... +} +``` + +### Step 6: Update sync.rs + +In `backend/windmill-native-triggers/src/sync.rs`: + +```rust +pub async fn sync_all_triggers(db: &DB) -> Result { + // ... + #[cfg(feature = "native_trigger")] + { + use crate::newservice::NewService; + + // ... existing service syncs ... + + // New service sync + 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); + } + // ... +} +``` + +### Step 7: Frontend Service Registry + +In `frontend/src/lib/components/triggers/native/utils.ts`: + +Add to `NATIVE_TRIGGER_SERVICES`, `getTriggerIconName()`, and `getServiceIcon()`. + +### Step 8: Frontend Trigger Form Component + +Create: `frontend/src/lib/components/triggers/native/services/newservice/NewServiceTriggerForm.svelte` + +### Step 9: Frontend Icon Component + +Create: `frontend/src/lib/components/icons/NewServiceIcon.svelte` + +### Step 10: Update NativeTriggerEditor + +Check `frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte` to ensure it dynamically loads form components based on service name. + +### Step 11: Workspace Integration UI + +Add your service to the `supportedServices` map in `frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte`: + +```typescript +const supportedServices: Record = { + // ... existing services ... + newservice: { + name: 'newservice', + displayName: 'New Service', + description: 'Connect to New Service for triggers', + icon: NewServiceIcon, + docsUrl: 'https://www.windmill.dev/docs/integrations/newservice', + requiresBaseUrl: false, // false for cloud services, true for self-hosted + setupInstructions: [ + 'Step 1: Create an OAuth app on the service', + 'Step 2: Configure the redirect URI shown below', + 'Step 3: Enter the client credentials below' + ] + } +} +``` + +### Step 12: Update `frontend/src/lib/components/triggers/utils.ts` + +Update ALL of these maps/functions: +1. `triggerIconMap` - import and add icon +2. `triggerDisplayNamesMap` - add display name +3. `triggerTypeOrder` in `sortTriggers()` - add type +4. `getLightConfig()` - add case for your service +5. `getTriggerLabel()` - add case for your service +6. `jobTriggerKinds` - add to array +7. `countPropertyMap` - add count property +8. `triggerSaveFunctions` - add save function + +### Step 13: Update TriggersBadge Component + +In `frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte`: + +1. Import the icon +2. Add to `baseConfig` with `countKey` (the dynamic `availableNativeServices` loop does NOT set `countKey`) +3. Add to the `allTypes` array + +### Step 14: Update TriggersWrapper.svelte + +In `frontend/src/lib/components/triggers/TriggersWrapper.svelte`: + +Add a `{:else if selectedTrigger.type === 'yourservice'}` case that renders `` with the same props pattern as the existing native trigger cases (e.g., `nextcloud`). + +### Step 15: Update AddTriggersButton.svelte + +In `frontend/src/lib/components/triggers/AddTriggersButton.svelte`: + +1. Add `yourserviceAvailable` state variable +2. Add `setYourserviceState()` async function using `isServiceAvailable('yourservice', $workspaceStore!)` +3. Call it at module level +4. Add a dropdown entry to `addTriggerItems` with `hidden: !yourserviceAvailable` + +### Step 16: Update TriggersEditor.svelte Delete Handling + +In `frontend/src/lib/components/triggers/TriggersEditor.svelte`: + +Add your service to the `nativeTriggerServices` map in `deleteDeployedTrigger()`. Native triggers use `NativeTriggerService.deleteNativeTrigger({ workspace, serviceName, externalId })` instead of the standard `path`-based delete. + +### Step 17: Update OpenAPI Spec and Regenerate Types + +Add to `JobTriggerKind` enum in `backend/windmill-api/openapi.yaml`, then: + +```bash +cd frontend && npm run generate-backend-client +``` + +--- + +## Special Patterns + +### Unified Service with `trigger_type` (Google Pattern) + +When a single service handles multiple trigger types (e.g., Google Drive + Calendar share OAuth and API patterns), use a single `ServiceName` variant with a discriminator field: + +```rust +pub enum GoogleTriggerType { Drive, Calendar } + +pub struct GoogleServiceConfig { + pub trigger_type: GoogleTriggerType, + // Drive-specific fields (only used when trigger_type = Drive) + pub resource_id: Option, + pub resource_name: Option, + // Calendar-specific fields (only used when trigger_type = Calendar) + pub calendar_id: Option, + pub calendar_name: Option, + // Metadata set after creation + pub google_resource_id: Option, + pub expiration: Option, +} +``` + +Branch in trait methods based on `trigger_type`. Frontend uses a `ToggleButtonGroup` to switch between types. This keeps the codebase simpler (one service, one OAuth flow, one set of routes). + +See `backend/windmill-native-triggers/src/google/` for the reference implementation. + +### Skipping update+get After Create (Google Pattern) + +Override `service_config_from_create_response()` to return `Some(config)` when the external_id is known before the create call: + +```rust +fn service_config_from_create_response( + &self, + data: &NativeTriggerData, + resp: &Self::CreateResponse, +) -> Option { + // Clone input config, add metadata from response + let mut config = data.service_config.clone(); + config.google_resource_id = Some(resp.resource_id.clone()); + config.expiration = Some(resp.expiration.clone()); + Some(serde_json::to_value(&config).unwrap()) +} +``` + +### Services with Absolute OAuth Endpoints (Google) + +Unlike self-hosted services where OAuth endpoints are relative paths appended to `base_url`, services like Google have absolute URLs: + +```rust +// Nextcloud: relative paths +ServiceName::Nextcloud => "/apps/oauth2/api/v1/token", +// Google: absolute URLs +ServiceName::Google => "https://oauth2.googleapis.com/token", +``` + +The `resolve_endpoint()` function handles both. For services with absolute endpoints: +- `base_url` can be empty +- `requiresBaseUrl: false` in the frontend workspace integration config +- Add `extra_auth_params()` if needed (Google requires `access_type=offline` and `prompt=consent`) + +### Channel-Based Push Notifications with Renewal (Google Pattern) + +For services using expiring watch channels instead of persistent webhooks: + +1. Store expiration in `service_config` (as part of `ServiceConfig`) +2. In `maintain_triggers()`, implement renewal logic instead of using `reconcile_with_external_state()`: + ```rust + async fn maintain_triggers(&self, db, workspace_id, triggers, oauth_data, synced, errors) { + for trigger in triggers { + if should_renew_channel(trigger) { + self.renew_channel(db, trigger, oauth_data).await; + } + } + } + ``` +3. Renewal: best-effort stop old channel, create new one with same external_id, update service_config with new expiration +4. Google example: Drive channels expire in 24h (renew when <1h left), Calendar channels expire in 7 days (renew when <1 day left) + +### reconcile_with_external_state (Nextcloud Pattern) + +The reusable function in `sync.rs` compares external triggers with DB state: +- Triggers missing externally: sets error "Trigger no longer exists on external service" +- Triggers present externally: clears errors, updates service_config if it differs + +Usage in `maintain_triggers()`: +```rust +let external_pairs: Vec<(String, serde_json::Value)> = /* fetch from external */; +crate::sync::reconcile_with_external_state( + db, workspace_id, Self::SERVICE_NAME, triggers, &external_pairs, synced, errors, +).await; +``` + +### Webhook Payload Processing + +Override `prepare_webhook()` to parse service-specific payloads into script/flow args: + +```rust +async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result { + let mut args = HashMap::new(); + args.insert("event_type".to_string(), Box::new(headers.get("x-event-type").cloned()) as _); + args.insert("payload".to_string(), Box::new(serde_json::from_str::(&body)?) as _); + Ok(PushArgsOwned { extra: None, args }) +} +``` + +Then register in `prepare_native_trigger_args()` in `lib.rs`: +```rust +pub async fn prepare_native_trigger_args(service_name, db, w_id, headers, body) -> Result> { + match service_name { + ServiceName::Google => { /* ... */ Ok(Some(args)) } + ServiceName::NewService => { /* ... */ Ok(Some(args)) } + ServiceName::Nextcloud => Ok(None), // Uses default body parsing + } +} +``` + +### Instance-Level OAuth Credentials + +When `workspace_integrations.oauth_data.instance_shared == true`, `decrypt_oauth_data()` reads `client_id` and `client_secret` from instance-level global settings instead of workspace-level. This allows admins to share OAuth app credentials across workspaces. + +The frontend handles this via the `generate_instance_connect_url` endpoint in `workspace_integrations.rs`. + +--- + +## Testing Checklist + +- [ ] Database migration runs successfully +- [ ] `cargo check -p windmill-native-triggers --features native_trigger` passes +- [ ] `npx svelte-check --threshold error` passes (in frontend/) +- [ ] Service appears in workspace integrations list +- [ ] OAuth flow completes successfully +- [ ] Can create a new trigger +- [ ] Can view trigger details +- [ ] Can update trigger configuration +- [ ] Can delete trigger +- [ ] Webhook receives and processes payloads +- [ ] Background sync works correctly (reconciliation or channel renewal) +- [ ] Error handling works (expired tokens, service unavailable) + +--- + +## Reference Implementations + +### Nextcloud (Self-Hosted, Update+Get Pattern) + +| File | Purpose | +|------|---------| +| `nextcloud/mod.rs` | Types: NextCloudOAuthData, NextcloudServiceConfig, NextCloudTriggerData | +| `nextcloud/external.rs` | External trait: uses update+get pattern, reconcile_with_external_state for sync | +| `nextcloud/routes.rs` | Additional route: `GET /events` | + +Key patterns: relative OAuth endpoints, base_url required, list_all + reconcile for sync, update returns JSON from get(). + +### Google (Cloud, Unified Service, Short Create) + +| File | Purpose | +|------|---------| +| `google/mod.rs` | Types: GoogleServiceConfig with trigger_type discriminator, GoogleTriggerType enum | +| `google/external.rs` | External trait: overrides service_config_from_create_response, channel renewal for sync | +| `google/routes.rs` | Additional routes: `GET /calendars`, `GET /drive/files`, `GET /drive/shared_drives` | + +Key patterns: absolute OAuth endpoints, empty base_url, trigger_type for Drive/Calendar, expiring watch channels with renewal, service_config_from_create_response skips update+get, get() reconstructs data from stored service_config (no external "get channel" API). diff --git a/.agents/skills/pr/SKILL.md b/.agents/skills/pr/SKILL.md new file mode 100644 index 0000000000..2efcc4e0a6 --- /dev/null +++ b/.agents/skills/pr/SKILL.md @@ -0,0 +1,109 @@ +--- +name: pr +description: Open a draft pull request on GitHub. MUST use when you want to create/open a PR. +--- + +# Pull Request Skill + +Create a draft pull request with a clear title and explicit description of changes. + +## Instructions + +1. **Analyze branch changes**: Understand all commits since diverging from main +2. **Push to remote**: Ensure all commits are pushed +3. **Create draft PR**: Always open as draft for review before merging + +## PR Title Format + +Follow conventional commit format for the PR title: +``` +: +``` + +### Types +- `feat`: New feature or capability +- `fix`: Bug fix +- `refactor`: Code restructuring +- `docs`: Documentation changes +- `chore`: Maintenance tasks +- `perf`: Performance improvements + +### Title Rules +- Keep under 70 characters +- Use lowercase, imperative mood +- No period at the end +- If `*_ee.rs` files were modified, prefix with `[ee]`: `[ee] : ` + +## PR Body Format + +The body MUST be explicit about what changed. Structure: + +```markdown +## Summary + + +## Changes +- +- +- + +## Test plan +- [ ] +- [ ] + +--- +Generated with [Claude Code](https://claude.com/claude-code) +``` + +## Execution Steps + +1. Run `git status` to check for uncommitted changes +2. Run `git log main..HEAD --oneline` to see all commits in this branch +3. Run `git diff main...HEAD` to see the full diff against main +4. Check if remote branch exists and is up to date: + ```bash + git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || echo "no upstream" + ``` +5. Push to remote if needed: `git push -u origin HEAD` +6. Create draft PR using gh CLI: + ```bash + gh pr create --draft --title ": " --body "$(cat <<'EOF' + ## Summary + + + ## Changes + - + - + + ## Test plan + - [ ] + - [ ] + + --- + Generated with [Claude Code](https://claude.com/claude-code) + EOF + )" + ``` +7. Return the PR URL to the user + +## EE Companion PR (when `*_ee.rs` files were modified) + +The `*_ee.rs` files in the windmill repo are **symlinks** to `windmill-ee-private` — changes won't appear in `git diff` of the windmill repo. Instead, check the EE repo for uncommitted or unpushed changes. + +Follow the full EE PR workflow in `docs/enterprise.md`. The key PR-specific details: + +1. Find the EE repo/worktree: see "Finding the EE Repo" in `docs/enterprise.md` +2. Check for changes: `git -C status --short` + - If there are no changes in the EE repo, skip this entire section +3. Follow steps 1–5 from the "EE PR Workflow" in `docs/enterprise.md` +4. Create the companion PR (title does NOT get the `[ee]` prefix): + ```bash + gh pr create --draft --repo windmill-labs/windmill-ee-private --title ": " --body "$(cat <<'EOF' + Companion PR for windmill-labs/windmill# + + --- + Generated with [Claude Code](https://claude.com/claude-code) + EOF + )" + ``` +5. Commit `ee-repo-ref.txt` and push the updated windmill branch diff --git a/.agents/skills/refine/SKILL.md b/.agents/skills/refine/SKILL.md new file mode 100644 index 0000000000..b96e97e8a2 --- /dev/null +++ b/.agents/skills/refine/SKILL.md @@ -0,0 +1,38 @@ +--- +name: refine +description: End-of-session reflection. Reviews friction encountered during the session and proposes updates to docs/ to capture lessons learned. +--- + +# Refine Skill + +Reflect on the current session and update documentation with lessons learned. + +## Instructions + +1. **Identify friction**: Review what happened in this session: + - Run `git diff main...HEAD --stat` to see what files were touched + - Think about: what was slow, what failed, what required multiple attempts, what information was missing or hard to find + +2. **Read current docs**: Read the docs that were relevant to this session: + - `docs/validation.md` + - `docs/enterprise.md` + - `docs/autonomous-mode.md` + - Any skills that were invoked + +3. **Propose updates**: For each piece of friction, decide if it warrants a doc update: + - **Missing knowledge**: Information you had to discover that should be documented + - **Wrong guidance**: Instructions that led you astray + - **Missing validation rule**: A check that should be in the validation matrix + - **New pattern**: A codebase pattern worth capturing for next time + +4. **Apply updates**: Edit the relevant `docs/` files. Keep changes minimal and specific — add only what would have saved time this session. + +5. **Report**: Summarize what was added/changed and why. + +## Rules + +- Only add knowledge confirmed by this session — no speculative additions +- Keep docs concise — add a line or two, not a paragraph +- If a whole new doc is needed, create it in `docs/` and add a pointer in `CLAUDE.md` +- Don't update skills unless a coding pattern was genuinely wrong +- Don't add things Claude already knows — only Windmill-specific knowledge diff --git a/.agents/skills/rust-backend/SKILL.md b/.agents/skills/rust-backend/SKILL.md new file mode 100644 index 0000000000..f0c52002bc --- /dev/null +++ b/.agents/skills/rust-backend/SKILL.md @@ -0,0 +1,107 @@ +--- +name: rust-backend +description: Rust coding guidelines for the Windmill backend. MUST use when writing or modifying Rust code in the backend directory. +--- + +# Windmill Rust Patterns + +Apply these Windmill-specific patterns when writing Rust code in `backend/`. + +## Error Handling + +Use `Error` from `windmill_common::error`. Return `Result` or `JsonResult`: + +```rust +use windmill_common::error::{Error, Result}; + +pub async fn get_job(db: &DB, id: Uuid) -> Result { + sqlx::query_as!(Job, "SELECT id, workspace_id FROM v2_job WHERE id = $1", id) + .fetch_optional(db) + .await? + .ok_or_else(|| Error::NotFound("job not found".to_string()))?; +} +``` + +Never panic in library code. Reserve `.unwrap()` for compile-time guarantees. + +## SQLx Patterns + +**Never use `SELECT *`** — always list columns explicitly. Critical for backwards compatibility when workers lag behind API version: + +```rust +// Correct +sqlx::query_as!(Job, "SELECT id, workspace_id, path FROM v2_job WHERE id = $1", id) + +// Wrong — breaks when columns are added +sqlx::query_as!(Job, "SELECT * FROM v2_job WHERE id = $1", id) +``` + +Use batch operations to avoid N+1: + +```rust +// Preferred — single query with IN clause +sqlx::query!("SELECT ... WHERE id = ANY($1)", &ids[..]).fetch_all(db).await? +``` + +Use transactions for multi-step operations. Parameterize all queries. + +## JSON Handling + +Prefer `Box` over `serde_json::Value` when storing/passing JSON without inspection: + +```rust +pub struct Job { + pub args: Option>, +} +``` + +Only use `serde_json::Value` when you need to inspect or modify the JSON. + +## Serde Optimizations + +```rust +#[derive(Serialize, Deserialize)] +pub struct Job { + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_job: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + #[serde(default)] + pub priority: i32, +} +``` + +## Async & Concurrency + +Never block the async runtime. Use `spawn_blocking` for CPU-intensive work: + +```rust +let result = tokio::task::spawn_blocking(move || expensive_computation(&data)).await?; +``` + +**Mutex selection**: Prefer `std::sync::Mutex` (or `parking_lot::Mutex`) for data protection. Only use `tokio::sync::Mutex` when holding locks across `.await` points. + +Use `tokio::sync::mpsc` (bounded) for channels. Avoid `std::thread::sleep` in async contexts. + +## Module Structure & Visibility + +- Use `pub(crate)` instead of `pub` when possible +- Place new code in the appropriate crate based on functionality +- API endpoints go in `windmill-api/src/` organized by domain +- Shared functionality goes in `windmill-common/src/` + +## Code Navigation + +Always use rust-analyzer LSP for go-to-definition, find-references, and type info. Do not guess at module paths. + +## Axum Handlers + +Destructure extractors directly in function signatures: + +```rust +async fn process_job( + Extension(db): Extension, + Path((workspace, job_id)): Path<(String, Uuid)>, + Query(pagination): Query, +) -> Result> { ... } +``` diff --git a/.agents/skills/svelte-frontend/SKILL.md b/.agents/skills/svelte-frontend/SKILL.md new file mode 100644 index 0000000000..57cac70302 --- /dev/null +++ b/.agents/skills/svelte-frontend/SKILL.md @@ -0,0 +1,80 @@ +--- +name: svelte-frontend +description: Svelte coding guidelines for the Windmill frontend. MUST use when writing or modifying code in the frontend directory. +--- + +# Windmill Svelte Patterns + +Apply these Windmill-specific patterns when writing Svelte code in `frontend/`. For general Svelte 5 syntax (runes, snippets, event handling), use the Svelte MCP server. + +## Windmill UI Components (MUST use) + +Always use Windmill's design-system components. Never use raw HTML elements. + +### Buttons — ` + - {#if hubRtSync.status === 'error'} - - Error syncing resource types : {JSON.stringify(hubRtSync.error)} - - {/if} - {/if} +
+ { + connectsManual = undefined + await loadResourceTypes() + connects = undefined + await loadConnects() + }} + /> +
{:else if step == 2 && manual}
+ {#if resourceTypeNotFound} +
+

+ Resource type '{resourceType}' not found in your workspace +

+ +
+ {/if} {#key resourceTypeInfo} {/key}
diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 85e7f05143..6905ed6931 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -8,6 +8,7 @@ ArrowUpRight, Building, DiffIcon, + FileJson, GitFork, Loader2, Trash2, @@ -43,11 +44,12 @@ import { userWorkspaces, workspaceStore } from '$lib/stores' import type { Kind } from '$lib/utils_deployable' - import { deployItem, getItemValue, getOnBehalfOfEmail } from '$lib/utils_workspace_deploy' + import { deployItem, getItemValue, getOnBehalfOf } from '$lib/utils_workspace_deploy' import Tooltip from './Tooltip.svelte' import OnBehalfOfSelector, { needsOnBehalfOfSelection, - type OnBehalfOfChoice + type OnBehalfOfChoice, + type OnBehalfOfDetails } from './OnBehalfOfSelector.svelte' import { sendUserToast } from '$lib/toast' import { deepEqual } from 'fast-equals' @@ -119,7 +121,7 @@ // Source workspace on_behalf_of emails (keyed by workspace/kind:path) let onBehalfOfInfo = $state>({}) let onBehalfOfChoice = $state>({}) - let customOnBehalfOfEmails = $state>({}) + let customOnBehalfOf = $state>({}) let deployTargetWorkspace = $derived(mergeIntoParent ? parentWorkspaceId : currentWorkspaceId) function getItemKey(diff: WorkspaceItemDiff): string { @@ -138,7 +140,7 @@ } else if (kind === 'flow') { const flow = await FlowService.getFlowByPath({ workspace, path }) return flow.summary - } else if (kind === 'app') { + } else if (kind === 'app' || kind === 'raw_app') { const app = await AppService.getAppByPath({ workspace, path }) return app.summary } else if (kind === 'folder') { @@ -154,7 +156,7 @@ async function fetchSummaries(diffs: WorkspaceItemDiff[]) { // Only fetch summaries for scripts, flows, and apps const itemsToFetch = diffs.filter((diff) => - ['script', 'flow', 'app', 'folder'].includes(diff.kind) + ['script', 'flow', 'app', 'raw_app', 'folder'].includes(diff.kind) ) for (const diff of itemsToFetch) { @@ -181,14 +183,16 @@ } async function fetchOnBehalfOfInfo(diffs: WorkspaceItemDiff[]) { - const flowsAndScripts = diffs.filter((d) => ['flow', 'script', 'app'].includes(d.kind)) + const flowsAndScripts = diffs.filter((d) => + ['flow', 'script', 'app', 'raw_app'].includes(d.kind) + ) for (const diff of flowsAndScripts) { for (const workspace of [currentWorkspaceId, parentWorkspaceId]) { const workspacedKey = getWorkspacedKey(workspace, getItemKey(diff)) if (onBehalfOfInfo[workspacedKey] !== undefined) continue try { - onBehalfOfInfo[workspacedKey] = await getOnBehalfOfEmail( + onBehalfOfInfo[workspacedKey] = await getOnBehalfOf( diff.kind as Kind, diff.path, workspace @@ -200,21 +204,21 @@ } } - // Get source workspace email for an item - function getSourceEmail(itemKey: string): string | undefined { + // Get source workspace on_behalf_of value for an item (email for runnables, permissioned_as for triggers) + function getSourceOnBehalfOf(itemKey: string): string | undefined { const sourceWorkspace = mergeIntoParent ? currentWorkspaceId : parentWorkspaceId return onBehalfOfInfo[getWorkspacedKey(sourceWorkspace, itemKey)] } - // Get target workspace email for an item (existing item in destination) - function getTargetEmail(itemKey: string): string | undefined { + // Get target workspace on_behalf_of value for an item (existing item in destination) + function getTargetOnBehalfOf(itemKey: string): string | undefined { const targetWorkspace = mergeIntoParent ? parentWorkspaceId : currentWorkspaceId return onBehalfOfInfo[getWorkspacedKey(targetWorkspace, itemKey)] } // Check if an item needs on_behalf_of selection function itemNeedsOnBehalfOfSelection(itemKey: string, kind: string): boolean { - return needsOnBehalfOfSelection(kind, getSourceEmail(itemKey)) + return needsOnBehalfOfSelection(kind, getSourceOnBehalfOf(itemKey)) } // Check if all required on_behalf_of selections are made @@ -228,12 +232,18 @@ }) ) - // Get the email to use for deployment based on user's choice - function getOnBehalfOfEmailForDeploy(itemKey: string): string | undefined { + /** + * Get the on_behalf_of value for deployment based on user's choice. + * Returns an email for flows/scripts/apps, or permissioned_as (u/username, g/group) for triggers/schedules. + */ + function getOnBehalfOfForDeploy(itemKey: string, kind: Kind): string | undefined { const choice = onBehalfOfChoice[itemKey] - if (choice === 'target') return getTargetEmail(itemKey) - if (choice === 'custom') return customOnBehalfOfEmails[itemKey] - // 'me' or undefined = don't pass, backend will use deploying user's email + if (choice === 'target') return getTargetOnBehalfOf(itemKey) + if (choice === 'custom') { + const details = customOnBehalfOf[itemKey] + return kind === 'trigger' ? details?.permissionedAs : details?.email + } + // 'me' or undefined = don't pass, backend will use deploying user's identity return undefined } @@ -301,7 +311,7 @@ path, workspaceFrom, workspaceTo: workspaceToDeployTo, - onBehalfOfEmail: getOnBehalfOfEmailForDeploy(statusPath) + onBehalfOf: getOnBehalfOfForDeploy(statusPath, kind) }) if (result.success) { @@ -865,7 +875,7 @@ {#snippet itemActions(item)} {@const diff = item.diff as WorkspaceItemDiff} {@const key = item.key} - {@const targetEmail = getTargetEmail(key)} + {@const targetOnBehalfOf = getTargetOnBehalfOf(key)} {@const isConflict = diff.ahead > 0 && diff.behind > 0} {@const existsInBothWorkspaces = !( (diff.exists_in_fork && !diff.exists_in_source) || @@ -875,17 +885,20 @@ {#if itemNeedsOnBehalfOfSelection(key, diff.kind)} { + onSelect={(choice, details) => { onBehalfOfChoice[key] = choice - if (email) customOnBehalfOfEmails[key] = email + if (details) customOnBehalfOf[key] = details }} kind={diff.kind} canPreserve={canPreserveOnBehalfOf} - customEmail={customOnBehalfOfEmails[key]} + customValue={customOnBehalfOf[key]?.permissionedAs} /> {/if} + {#if diff.kind === 'raw_app'} + Raw + {/if} {#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead == 0 && diff.behind > 0} { + computePreview={async ({ values }) => { if (dbTableEditorState.alterTableKey && dbTableEditorAlterTableData.current) { let diff = diffTableEditorValues(dbTableEditorAlterTableData.current, values) - let queries = dbSchemaOps.previewAlterSql({ - values: diff, - schema: selected.schemaKey - }) + let sql = await dbSchemaOps.previewAlterSql({ values: diff, schema: selected.schemaKey }) let alert = !dbSupportsTransactionalDdl(dbType) ? { title: capitalize(dbType) + ' does not support transactional DDL', body: 'Any of these statements failing may leave your database in an intermediate state.' } : undefined - return { sql: queries.join('\n'), ...(alert ? { alert } : {}) } + return { sql, ...(alert ? { alert } : {}) } } else { - return { sql: dbSchemaOps.previewCreateSql({ values, schema: selected.schemaKey }) } + let sql = await dbSchemaOps.previewCreateSql({ values, schema: selected.schemaKey }) + return { sql } } }} computeBtnProps={({ values }) => { if (dbTableEditorState.alterTableKey && dbTableEditorAlterTableData.current) { let diff = diffTableEditorValues(dbTableEditorAlterTableData.current, values) - let queries = dbSchemaOps.previewAlterSql({ - values: diff, - schema: selected.schemaKey - }) - if (!queries.length) { + if (!diff.operations.length) { return { text: 'No changes detected', disabled: true } } return { - text: `Alter table (${pluralize(queries.length, 'change')} detected)` + text: `Alter table (${pluralize(diff.operations.length, 'change')} detected)` } } else { return { text: 'Create table' } diff --git a/frontend/src/lib/components/DBTable.svelte b/frontend/src/lib/components/DBTable.svelte index 72bd3aa370..6566542b77 100644 --- a/frontend/src/lib/components/DBTable.svelte +++ b/frontend/src/lib/components/DBTable.svelte @@ -115,8 +115,8 @@ refresh?.() sendUserToast('Row deleted') }) - .catch(() => { - sendUserToast('Error deleting row', true) + .catch((e) => { + sendUserToast(`Error deleting row: ${e?.message ?? e}`, true) }) } }) diff --git a/frontend/src/lib/components/DBTableEditor.svelte b/frontend/src/lib/components/DBTableEditor.svelte index d9e52dacc3..2146e8909a 100644 --- a/frontend/src/lib/components/DBTableEditor.svelte +++ b/frontend/src/lib/components/DBTableEditor.svelte @@ -42,6 +42,7 @@ +{#snippet depBadge(dep: string)} + {#if existingDeps.has(dep)} + + {dep} + + + {:else} + + Workspace dependency '{dep}' not found. Create it in workspace settings to enable shared + runners. + + + + {dep} + + {/if} +{/snippet} + +{#snippet tagRow(tag: string, info: SelectedTagInfo | undefined)} +
+
+ {#if info?.type === 'flow' && info.runners && info.runners.length > 0} + + {:else} +
+ {/if} +
+ {#if info} + {#if info.type === 'flow'} + + {:else} + + {/if} + {info.path} + ({info.workspace}) + + {#if !tagRunnerGroup.has(tag)} + {#if info.workspaceDeps} + {#each info.workspaceDeps as dep} + {@render depBadge(dep)} + {/each} + {/if} + {#if info.type === 'flow' && info.runners} + + {info.runners.length} runner{info.runners.length !== 1 ? 's' : ''} + + {:else if info.language} + {info.language} + {/if} + {/if} + {:else} + {tag} + {/if} +
+ {#if !disabled} + + {/if} +
+ + {#if info?.type === 'flow' && info.expanded && info.runners} +
+ {#each info.runners as runner (runner.stepId)} +
+ {runner.stepId} + {#if runner.stepSummary} + {runner.stepSummary} + {/if} + + {runner.isInline ? runner.language : runner.scriptPath} + +
+ {/each} +
+ {/if} +
+{/snippet} +
- {#if selectedTags.length > 0}
-
- {#each selectedTags as tag (tag)} - {@const info = selectedTagsInfo.get(tag)} +
+ + {#each runnerGroups as group (`${group.depName}:${group.language}`)}
-
- {#if info?.type === 'flow' && info.runners && info.runners.length > 0} - - {:else} -
- {/if} -
-
- {#if info} - {#if info.type === 'flow'} - - {:else} - - {/if} - {info.path} - ({info.workspace}) - {#if info.type === 'flow' && info.runners} - - {info.runners.length} runner{info.runners.length !== 1 ? 's' : ''} - - {:else if info.type === 'script'} - 1 runner - {/if} - {:else} - {tag} - {/if} -
-
- {#if !disabled} - - {/if} +
+ + Shared runner + + {@render depBadge(group.depName)} + {group.language}
+
+ {#each group.tags as tag (tag)} + {@const info = selectedTagsInfo.get(tag)} + {@render tagRow(tag, info)} + {/each} +
+
+ {/each} - {#if info?.type === 'flow' && info.expanded && info.runners} -
- {#each info.runners as runner (runner.stepId)} -
- {runner.stepId} - {#if runner.stepSummary} - {runner.stepSummary} - {/if} - - {runner.isInline ? runner.language : runner.scriptPath} - -
- {/each} -
+ + {#each standaloneTags as tag (tag)} + {@const info = selectedTagsInfo.get(tag)} +
+ {#if info?.type === 'flow'} + + {:else} + + {/if} + {info?.path ?? tag} + ({info?.workspace ?? ''}) + + {#if info?.workspaceDeps} + {#each info.workspaceDeps as dep} + {@render depBadge(dep)} + {/each} + {/if} + {#if info?.type === 'flow' && info.runners} + + {info.runners.length} runner{info.runners.length !== 1 ? 's' : ''} + + {:else if info?.language} + {info.language} + {/if} + {#if !disabled} + {/if}
{/each} @@ -585,15 +824,18 @@ {/if}
- {runnable.displayName} + {runnable.displayName} {#if runnable.type === 'flow' && runnable.runners} {runnable.runners.length} {/if} - + {#if runnable.workspaceDeps} + {#each runnable.workspaceDeps as dep} + {@render depBadge(dep)} + {/each} + {/if} + {runnable.type === 'flow' ? 'flow' : runnable.language} diff --git a/frontend/src/lib/components/DeployWorkspace.svelte b/frontend/src/lib/components/DeployWorkspace.svelte index c5e289a85b..9f1fd71146 100644 --- a/frontend/src/lib/components/DeployWorkspace.svelte +++ b/frontend/src/lib/components/DeployWorkspace.svelte @@ -14,7 +14,7 @@ import Button from './common/button/Button.svelte' import Tooltip from './Tooltip.svelte' import Alert from './common/alert/Alert.svelte' - import { DiffIcon, Loader2 } from 'lucide-svelte' + import { DiffIcon, FileJson, Loader2 } from 'lucide-svelte' import Badge from './common/badge/Badge.svelte' import DiffDrawer from './DiffDrawer.svelte' import { @@ -26,7 +26,7 @@ checkItemExists, deployItem, getItemValue, - getOnBehalfOfEmail + getOnBehalfOf } from '$lib/utils_workspace_deploy' import type { App } from './apps/types' import { getAllGridItems } from './apps/editor/appUtils' @@ -35,7 +35,8 @@ import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte' import OnBehalfOfSelector, { needsOnBehalfOfSelection, - type OnBehalfOfChoice + type OnBehalfOfChoice, + type OnBehalfOfDetails } from './OnBehalfOfSelector.svelte' import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte' @@ -77,7 +78,7 @@ // Target workspace on_behalf_of emails (keyed by kind:path) let targetOnBehalfOfInfo = $state>({}) let onBehalfOfChoice = $state>({}) - let customOnBehalfOfEmails = $state>({}) + let customOnBehalfOf = $state>({}) let canPreserveOnBehalfOf = $state(false) // Check if an item needs on_behalf_of selection @@ -85,12 +86,18 @@ return needsOnBehalfOfSelection(kind, sourceOnBehalfOfInfo[statusPath]) } - // Get the email to use for deployment based on user's choice - function getOnBehalfOfEmailForDeploy(statusPath: string): string | undefined { + /** + * Get the on_behalf_of value for deployment based on user's choice. + * Returns an email for flows/scripts/apps, or permissioned_as (u/username, g/group) for triggers/schedules. + */ + function getOnBehalfOfForDeploy(statusPath: string, kind: Kind): string | undefined { const choice = onBehalfOfChoice[statusPath] if (choice === 'target') return targetOnBehalfOfInfo[statusPath] - if (choice === 'custom') return customOnBehalfOfEmails[statusPath] - // 'me' or undefined = don't pass, backend will use deploying user's email + if (choice === 'custom') { + const details = customOnBehalfOf[statusPath] + return kind === 'trigger' ? details?.permissionedAs : details?.email + } + // 'me' or undefined = don't pass, backend will use deploying user's identity return undefined } @@ -99,9 +106,7 @@ if (!$superadmin) { const targetUser = await UserService.whoami({ workspace: workspaceToDeployTo! }) canPreserveOnBehalfOf = - targetUser.is_admin || - targetUser.groups?.includes('wm_deployers') || - false + targetUser.is_admin || targetUser.groups?.includes('wm_deployers') || false } else { canPreserveOnBehalfOf = true } @@ -147,7 +152,7 @@ )) { const key = computeStatusPath(dep.kind, dep.path) try { - sourceOnBehalfOfInfo[key] = await getOnBehalfOfEmail( + sourceOnBehalfOfInfo[key] = await getOnBehalfOf( dep.kind, dep.path, $workspaceStore!, @@ -157,7 +162,7 @@ sourceOnBehalfOfInfo[key] = undefined } try { - targetOnBehalfOfInfo[key] = await getOnBehalfOfEmail( + targetOnBehalfOfInfo[key] = await getOnBehalfOf( dep.kind, dep.path, workspaceToDeployTo!, @@ -297,7 +302,7 @@ workspaceFrom: $workspaceStore!, workspaceTo: workspaceToDeployTo!, additionalInformation, - onBehalfOfEmail: getOnBehalfOfEmailForDeploy(statusPath) + onBehalfOf: getOnBehalfOfForDeploy(statusPath, kind) }) if (result.success) { @@ -460,24 +465,27 @@ {@const statusPath = item.key} {@const exists = allAlreadyExists[statusPath]} {@const status = deploymentStatus[statusPath]} - {@const targetEmail = targetOnBehalfOfInfo[statusPath]} + {@const targetValue = targetOnBehalfOfInfo[statusPath]} {#if itemNeedsOnBehalfOfSelection(statusPath, item.kind)} { + onSelect={(choice, details) => { onBehalfOfChoice[statusPath] = choice - if (email) customOnBehalfOfEmails[statusPath] = email + if (details) customOnBehalfOf[statusPath] = details }} kind={item.kind} canPreserve={canPreserveOnBehalfOf} - customEmail={customOnBehalfOfEmails[statusPath]} + customValue={customOnBehalfOf[statusPath]?.permissionedAs} /> {/if} + {#if item.kind === 'raw_app'} + Raw + {/if} {#if exists === false} {#if item.include} {#if kind === 'trigger'} - You must set the "edited by" user for all triggers before deploying + You must set the "permissioned as" user for all triggers before deploying - The "edited by" field defines which user's permissions will be applied - when the trigger runs. Make sure this is set to an appropriate user - before deploying. + The "permissioned as" field defines which user's permissions will be applied when + the trigger fires. Make sure this is set appropriately before deploying. {:else} You must set the "on behalf of" user for all items before deploying - The "run on behalf of" field defines which user's permissions will be - applied during execution. Make sure this is set to an appropriate user - before deploying. + The "run on behalf of" field defines which user's permissions will be applied + during execution. Make sure this is set to an appropriate user before deploying. {/if} diff --git a/frontend/src/lib/components/Dev.svelte b/frontend/src/lib/components/Dev.svelte index d13d6eee97..d9ec19a211 100644 --- a/frontend/src/lib/components/Dev.svelte +++ b/frontend/src/lib/components/Dev.svelte @@ -34,6 +34,7 @@ import type { FlowEditorContext, FlowInput, FlowInputEditorState } from './flows/types' import { SelectionManager } from './graph/selectionUtils.svelte' import { NoteEditor, setNoteEditorContext } from './graph/noteEditor.svelte' + import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte' import { dfs } from './flows/dfs' import { loadSchemaFromModule } from './flows/flowInfers' import { CornerDownLeft, Play } from 'lucide-svelte' @@ -145,6 +146,7 @@ lock?: string isCodebase?: boolean tag?: string + modules?: { [key: string]: import('$lib/gen').ScriptModule } | null } let currentScript: LastEditScript | undefined = $state(undefined) @@ -206,7 +208,9 @@ done(x) { loadPastTests() } - } + }, + undefined, + currentScript.modules ) } else { sendUserToast(`Bundle received ${lastCommandId} was obsolete, ignoring`, true) @@ -392,7 +396,11 @@ currentScript.language, args, currentScript.tag, - useLock ? currentScript.lock : undefined + useLock ? currentScript.lock : undefined, + undefined, + undefined, + undefined, + currentScript.modules ) } } @@ -554,6 +562,11 @@ }) setNoteEditorContext(noteEditor) + // Set up GroupEditor context for group editing capabilities + const groupEditor = new GroupEditor(flowStore) + let canCreateGroup = $state({ val: false }) + setGroupEditorContext(groupEditor, canCreateGroup) + let lastSent: OpenFlow | undefined = undefined function updateFlow(flow: OpenFlow) { if (lockChanges) { diff --git a/frontend/src/lib/components/DiffEditor.svelte b/frontend/src/lib/components/DiffEditor.svelte index 1d252d082a..3fb26334b9 100644 --- a/frontend/src/lib/components/DiffEditor.svelte +++ b/frontend/src/lib/components/DiffEditor.svelte @@ -102,8 +102,8 @@ }) } - if (defaultLang !== undefined) { - setupModel(defaultLang, defaultOriginal, defaultModified, defaultModifiedLang) + if (defaultLang !== undefined || defaultOriginal !== undefined || defaultModified !== undefined) { + setupModel(defaultLang ?? 'plaintext', defaultOriginal, defaultModified, defaultModifiedLang) } } diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 779bc2bf93..3a59b0d80c 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -7,6 +7,7 @@ diff --git a/frontend/src/lib/components/EditorBar.svelte b/frontend/src/lib/components/EditorBar.svelte index cdaec4c44c..ee2108711c 100644 --- a/frontend/src/lib/components/EditorBar.svelte +++ b/frontend/src/lib/components/EditorBar.svelte @@ -79,7 +79,7 @@ iconOnly?: boolean validCode?: boolean kind?: 'script' | 'trigger' | 'approval' - template?: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative' | 'claudesandbox' + template?: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative' | 'claudesandbox' | 'wac_python' | 'wac_typescript' collabMode?: boolean collabLive?: boolean collabUsers?: { name: string }[] @@ -1096,7 +1096,7 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
{@render right?.()} - {#if scriptPath && !noHistory} + {#if scriptPath && !noHistory && customUi?.history != false}
-
+
{#each presets as preset} {@render presetTag(preset)} {/each} diff --git a/frontend/src/lib/components/FirstStepInputs.svelte b/frontend/src/lib/components/FirstStepInputs.svelte index 30399e20f7..cf936b64f0 100644 --- a/frontend/src/lib/components/FirstStepInputs.svelte +++ b/frontend/src/lib/components/FirstStepInputs.svelte @@ -85,7 +85,6 @@ > flowStore.val.value.modules }) diff --git a/frontend/src/lib/components/FlowGraphDiffViewer.svelte b/frontend/src/lib/components/FlowGraphDiffViewer.svelte index f66a582eb5..62e5b208c2 100644 --- a/frontend/src/lib/components/FlowGraphDiffViewer.svelte +++ b/frontend/src/lib/components/FlowGraphDiffViewer.svelte @@ -140,6 +140,7 @@ @@ -64,6 +63,7 @@ failureModule={flow?.value?.failure_module} preprocessorModule={flow?.value?.preprocessor_module} notes={flow?.value?.notes} + groups={flow?.value?.groups} onSelect={(nodeId) => { if (nodeId === 'Trigger') { dispatch('triggerDetail') diff --git a/frontend/src/lib/components/FlowPreviewContent.svelte b/frontend/src/lib/components/FlowPreviewContent.svelte index f702b84397..9c4ed4231e 100644 --- a/frontend/src/lib/components/FlowPreviewContent.svelte +++ b/frontend/src/lib/components/FlowPreviewContent.svelte @@ -437,7 +437,8 @@ startIcon={{ icon: isRunning ? RefreshCw : Play }} size="sm" btnClasses="w-full max-w-lg" - on:click={() => recordingMode ? recordAndTest() : runPreview(previewArgs.val, undefined)} + on:click={() => + recordingMode ? recordAndTest() : runPreview(previewArgs.val, undefined)} id="flow-editor-test-flow-drawer" shortCut={{ Icon: CornerDownLeft }} > @@ -652,6 +653,13 @@ onDone={async ({ job: completedJob }) => { isRunning = false $executionCount = $executionCount + 1 + // Reset 'initial' flags for modules that were part of this flow test, + // so OutputPicker no longer shows "Run loaded from history" + for (const mod of completedJob.flow_status?.modules ?? []) { + if (mod.id) { + stepHistoryLoader?.resetInitial(mod.id) + } + } if (flowRecording.active) { lastRecording = flowRecording.stop() setActiveRecording(undefined) @@ -668,6 +676,8 @@ {render} {customUi} showLogsWithResult + notes={flowStore.val.value.notes} + groups={flowStore.val.value.groups} /> {:else if loadingHistory}
diff --git a/frontend/src/lib/components/FlowStatusViewer.svelte b/frontend/src/lib/components/FlowStatusViewer.svelte index 950eb7354f..1642193a46 100644 --- a/frontend/src/lib/components/FlowStatusViewer.svelte +++ b/frontend/src/lib/components/FlowStatusViewer.svelte @@ -5,7 +5,7 @@ import type { DurationStatus, FlowStatusViewerContext, GraphModuleState } from './graph' import { isOwner as loadIsOwner, type StateStore } from '$lib/utils' import { userStore, workspaceStore } from '$lib/stores' - import type { CompletedJob, Job } from '$lib/gen' + import type { CompletedJob, FlowNote, FlowValue, Job } from '$lib/gen' interface Props { jobId: string @@ -34,6 +34,8 @@ onJobsLoaded?: ({ job, force }: { job: Job; force: boolean }) => void onDone?: ({ job }: { job: CompletedJob }) => void showLogsWithResult?: boolean + notes?: FlowNote[] + groups?: FlowValue['groups'] } let { @@ -59,7 +61,9 @@ onStart, onJobsLoaded, onDone, - showLogsWithResult = false + showLogsWithResult = false, + notes: notesProp = undefined, + groups: groupsProp = undefined }: Props = $props() let lastJobId: string = untrack(() => jobId) @@ -171,5 +175,7 @@ } }} {showLogsWithResult} + notes={notesProp} + groups={groupsProp} /> {/key} diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index 25731f82c8..8aaeadb5ef 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -9,11 +9,15 @@ type FlowModuleValue, type FlowModule, ResourceService, - type CompletedJob + type CompletedJob, + type WorkflowStatus, + type FlowNote, + type FlowValue } from '$lib/gen' import { workspaceStore } from '$lib/stores' import { base } from '$lib/base' import FlowJobResult from './FlowJobResult.svelte' + import WorkflowTimeline from './WorkflowTimeline.svelte' import DisplayResult from './DisplayResult.svelte' import { getContext, setContext, tick, untrack } from 'svelte' @@ -132,6 +136,8 @@ } showLogsWithResult?: boolean showJobDetailHeader?: boolean + notes?: FlowNote[] + groups?: FlowValue['groups'] } let { @@ -171,7 +177,9 @@ onDone = undefined, toolCallStore, showLogsWithResult = false, - showJobDetailHeader = false + showJobDetailHeader = false, + notes: notesProp = undefined, + groups: groupsProp = undefined }: Props = $props() let getTopModuleStates = $derived(topModuleStates ?? localModuleStates) @@ -234,13 +242,28 @@ }) let jobResults: any[] = $state( - untrack(() => flowJobIds)?.flowJobs?.map((x, id) => `iter #${id + 1} not loaded by frontend yet`) ?? [] + untrack(() => flowJobIds)?.flowJobs?.map( + (x, id) => `iter #${id + 1} not loaded by frontend yet` + ) ?? [] ) + function asWorkflowStatus(x: any): Record { + if (!x || typeof x !== 'object') return {} + const result: Record = {} + for (const [k, v] of Object.entries(x)) { + if (!k.startsWith('_') || k.startsWith('_step/')) result[k] = v as WorkflowStatus + } + return result + } + + function getStepResults(x: any): Record { + return x?._checkpoint?.completed_steps ?? {} + } + let retry_selected = $state('') let timeout: number | undefined = undefined - let expandedSubflows: Record = $state({}) + let expandedSubflows: Record = $state({}) let selectionManager = new SelectionManager() @@ -669,10 +692,7 @@ } }) .catch((e) => { - console.error( - `Could not load inner module duration status for job ${mod.job}`, - e - ) + console.error(`Could not load inner module duration status for job ${mod.job}`, e) }) } } else { @@ -879,7 +899,8 @@ tag: job.tag, started_at, parent_module: mod['parent_module'], - script_hash: job.script_hash + script_hash: job.script_hash, + workflow_as_code_status: job['workflow_as_code_status'] }, force ) @@ -917,8 +938,8 @@ retries: mod?.failed_retries?.length, skipped: mod.skipped, agent_actions: mod.agent_actions, - script_hash: job.script_hash - // retries: flowStateStore?.raw_flow + script_hash: job.script_hash, + workflow_as_code_status: job['workflow_as_code_status'] }, force ) @@ -1138,7 +1159,7 @@ function allModulesForTimeline( modules: FlowModule[], - expandedSubflows: Record + expandedSubflows: Record ): FlowModuleForTimeline[] { const ids = dfs(modules, (x) => ({ id: x.id, type: x.value.type }) as FlowModuleForTimeline, { skipToolNodes: true @@ -1150,7 +1171,7 @@ ): FlowModuleForTimeline[] { return ids.concat( ids.flatMap(({ id }) => { - let fms = expandedSubflows[id] + let fms = expandedSubflows[id]?.modules let oid = id.split(':').pop() if (!oid) { return [] @@ -1885,7 +1906,8 @@ earlyStop={job.raw_flow?.skip_expr !== undefined} cache={job.raw_flow?.cache_ttl !== undefined} modules={job.raw_flow?.modules ?? []} - notes={job.raw_flow?.notes ?? []} + notes={notesProp ?? job.raw_flow?.notes ?? []} + groups={groupsProp ?? job.raw_flow?.groups} failureModule={job.raw_flow?.failure_module} preprocessorModule={job.raw_flow?.preprocessor_module} allowSimplifiedPoll={false} @@ -1978,7 +2000,9 @@ {#if job.args} {:else} @@ -2048,11 +2072,28 @@
Inputs
{/if} + {#if node.workflow_as_code_status} +
+
Workflow timeline
+ +
+ {/if}
{/if}
- {#if isOwner || resumeUrl} -
- {#if !hide_cancel} -
-
- {/if} +
+ {#if !hide_cancel}
- +
- - {#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema} -
- -
- - The payload is optional, it is passed to the following step through the `resume` - variable - - {/if} + {/if} +
+
- {:else} - You cannot resume the flow yourself without receiving the resume secret since you are not an - owner of {job.script_path} and the approval step did not contain the resume url at key `resume` - {/if} + + {#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema} +
+ +
+ + The payload is optional, it is passed to the following step through the `resume` variable + + {/if} +
diff --git a/frontend/src/lib/components/GroupEditor.svelte b/frontend/src/lib/components/GroupEditor.svelte index 5a8c4cb411..ab1d3a6d63 100644 --- a/frontend/src/lib/components/GroupEditor.svelte +++ b/frontend/src/lib/components/GroupEditor.svelte @@ -119,9 +119,9 @@
{#if name === 'wm_deployers'} - Members of this group can preserve the original author (on_behalf_of / edited_by) when - deploying scripts, flows, apps, and triggers to this workspace. Without this permission, - deployed items will be reassigned to the deploying user. + Members of this group can preserve the original author (on_behalf_of / permissioned_as) when + deploying scripts, flows, apps, triggers, and schedules to this workspace. Without this + permission, deployed items will be reassigned to the deploying user. {/if}
+ + {#if $superadmin} +
+

Instance Role

+

Assign an instance-level role to all members of this group. Superadmin grants full admin + access, devops grants read-only admin visibility.

+ { + let role = e.detail + await GroupService.updateInstanceGroup({ + name, + requestBody: { + new_summary: instance_group?.summary ?? '', + instance_role: role + } + }) + if (instance_group) { + instance_group.instance_role = + role === 'user' ? undefined : (role as 'superadmin' | 'devops') + } + dispatch('update') + sendUserToast('Instance role updated') + }} + > + {#snippet children({ item })} + + + + {/snippet} + +
+ {/if} + + {#if instance_group.workspaces && instance_group.workspaces.length > 0} +
+

Workspace Membership

+ + {#snippet headerRow()} + + Workspace + Role + + {/snippet} + {#snippet body()} + + {#each instance_group?.workspaces ?? [] as ws (ws.workspace_id)} + + {ws.workspace_name ?? ws.workspace_id} + {ws.role} + + {/each} + + {/snippet} + +
+ {/if} +

Members ({#if members?.length != undefined}{members?.length ?? 0}{:else} {#snippet headerRow()} - + user - {/snippet} + {/snippet} {#snippet body()} - - {#each members as { member_email }} + + {#each members as { member_email } (member_email)} + {member_email} remove - {/each} + + {/each} - {/snippet} + {/snippet} {:else}
- {#each new Array(6) as _} + {#each new Array(6) as _, i (i)} {/each}
diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index 1780bc1d80..a429920e89 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -22,6 +22,8 @@ import CriticalAlertChannels from './instanceSettings/CriticalAlertChannels.svelte' import SmtpSettings from './instanceSettings/SmtpSettings.svelte' import SecretBackendConfig from './instanceSettings/SecretBackendConfig.svelte' + import GhesAppSettings from './instanceSettings/GhesAppSettings.svelte' + import WsConnectivityTest from './instanceSettings/WsConnectivityTest.svelte' import IndexerMemorySettings from './instanceSettings/IndexerMemorySettings.svelte' import IndexerJobIndexSettings from './instanceSettings/IndexerJobIndexSettings.svelte' import IndexerLogIndexSettings from './instanceSettings/IndexerLogIndexSettings.svelte' @@ -283,8 +285,18 @@ {/if} {:else}

@@ -710,6 +722,10 @@ {:else if setting.fieldType == 'secret_backend'} + {:else if setting.fieldType == 'github_enterprise_app'} + + {:else if setting.fieldType == 'ws_connectivity'} + {/if} {#if hasError} diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index 64fdfad16d..18514b3836 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -20,6 +20,7 @@ import Toggle from './Toggle.svelte' import SettingsFooter from './workspaceSettings/SettingsFooter.svelte' import SettingsPageHeader from './settings/SettingsPageHeader.svelte' + import WorkspaceRegistries from './instanceSettings/WorkspaceRegistries.svelte' interface Props { tab?: string @@ -30,6 +31,7 @@ quickSetup?: boolean yamlMode?: boolean hasUnsavedChanges?: boolean + hasAnyInvalid?: boolean } let { @@ -40,7 +42,8 @@ onNavigateToTab, quickSetup = false, yamlMode = $bindable(false), - hasUnsavedChanges = $bindable(false) + hasUnsavedChanges = $bindable(false), + hasAnyInvalid = $bindable(false) }: Props = $props() let values: Writable> = writable({}) @@ -77,7 +80,8 @@ smtp_settings: {}, otel: {}, indexer_settings: {}, - critical_error_channels: [] + critical_error_channels: [], + github_enterprise_app: {} } function applyFormDefaults(vals: Record): void { @@ -264,12 +268,13 @@ async function downloadStats() { try { downloadingStats = true - const encryptedData = await SettingService.getStats() - const blob = new Blob([encryptedData], { type: 'application/octet-stream' }) + const result = await SettingService.getStats() + const blob = new Blob([result.data ?? ''], { type: 'application/json' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - a.download = `windmill-telemetry-${new Date().toISOString().split('T')[0]}.enc` + const date = new Date().toISOString().split('T')[0] + a.download = `windmill-telemetry-${date}-${result.signature}.json` document.body.appendChild(a) a.click() document.body.removeChild(a) @@ -398,6 +403,9 @@ obj['require_preexisting_user_for_oauth'] = reqPreexisting } } + if (category === 'Registries') { + obj['workspace_registries'] = vals['workspace_registries'] ?? null + } return YAML.stringify(obj) } @@ -438,6 +446,10 @@ return result }) + $effect(() => { + hasAnyInvalid = Object.values(invalidCategories).some(Boolean) + }) + export function isDirty(category: string): boolean { return dirtyCategories[category] ?? false } @@ -459,6 +471,11 @@ const v = initialValues[s.key] $values[s.key] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined } + if (category === 'Registries') { + const v = initialValues['workspace_registries'] + $values['workspace_registries'] = + v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined + } } } @@ -562,6 +579,19 @@ } } + // Handle workspace_registries (saved separately like oauths) + if (category === 'Registries') { + if (!deepEqual(initialValues['workspace_registries'], $values['workspace_registries'])) { + await SettingService.setGlobal({ + key: 'workspace_registries', + requestBody: { value: $values['workspace_registries'] ?? null } + }) + initialValues['workspace_registries'] = $values['workspace_registries'] + ? JSON.parse(JSON.stringify($values['workspace_registries'])) + : undefined + } + } + if (licenseKeySet) setLicense() if (shouldReloadPage) { @@ -588,7 +618,8 @@ .filter((s) => s.fieldType === 'password' || s.fieldType === 'license_key') .map((s) => s.key), 'ducklake_user_pg_pwd', - 'jwt_secret' + 'jwt_secret', + 'workspace_registries' ]) // Settings that should never appear in YAML export/import @@ -601,7 +632,8 @@ secret_backend: ['token'], object_store_cache_config: ['secret_key', 'serviceAccountKey'], custom_instance_pg_databases: ['user_pwd'], - rsa_keys: ['private_key'] + rsa_keys: ['private_key'], + github_enterprise_app: ['private_key'] } /** Returns SENSITIVE_UNCHANGED if the value is non-empty and matches the initial */ @@ -930,28 +962,28 @@ {/if} {:else if category == 'Telemetry'} -
- Anonymous usage data is collected to help improve Windmill. -
The following information is collected: -
    -
  • version of your instances
  • -
  • instance base URL
  • -
  • job usage (language, total duration, count)
  • -
  • login type usage (login type, count)
  • -
  • worker usage (worker, worker instance, vCPUs, memory)
  • -
  • user usage (author count, operator count)
  • -
  • superadmin email addresses
  • -
  • vCPU usage
  • -
  • memory usage
  • -
  • development instance status
  • -
-
{#if $enterpriseLicense}
- On Enterprise Edition, you must send data to check that usage is in line with the terms of - the subscription. You can either enable telemetry or regularly send usage data by clicking - the button below. For air-gapped instances, you can download the telemetry data and send - it manually. + Telemetry is required on Enterprise Edition for license compliance. When minimal telemetry + is enabled, only the following data is sent: +
    +
  • version of your instance
  • +
  • instance base URL
  • +
  • login type usage (login type, count)
  • +
  • worker usage (worker, worker instance, vCPUs, memory)
  • +
  • user usage (author count, operator count)
  • +
  • superadmin email addresses
  • +
  • development instance status
  • +
+
When minimal telemetry is disabled, the following is also collected: +
    +
  • job usage (language, total duration, count)
  • +
  • git sync repo count (sync vs promotion mode)
  • +
  • AI chat usage (provider, model, mode, session count, message count — last 30 days)
  • +
+
For air-gapped instances, you can download the telemetry data and send it manually.
+ {:else} +
+ Anonymous usage data is collected to help improve Windmill. +
The following information is collected: +
    +
  • version of your instance
  • +
  • instance base URL
  • +
  • job usage (language, total duration, count)
  • +
  • login type usage (login type, count)
  • +
  • worker usage (worker, worker instance, vCPUs, memory)
  • +
  • user usage (author count, operator count)
  • +
  • development instance status
  • +
  • AI chat usage (provider, model, mode, session count, message count — last 30 days)
  • +
+
{/if} {:else if category == 'Jobs'} + {:else if category == 'GitHub Enterprise App'} + {:else if category == 'Auth/OAuth/SAML'} + {#if category === 'Registries'} + + {/if} + {#if !loading && !quickSetup && !hideTabs} | null ): Promise { return abstractRun( () => @@ -310,7 +311,8 @@ tag, lock, script_hash: hash, - flow_path: flowPath + flow_path: flowPath, + modules: modules ?? undefined } }), callbacks diff --git a/frontend/src/lib/components/Login.svelte b/frontend/src/lib/components/Login.svelte index b9c0de018d..c1a2a7ebdb 100644 --- a/frontend/src/lib/components/Login.svelte +++ b/frontend/src/lib/components/Login.svelte @@ -164,7 +164,8 @@ workspace: $workspaceStore! }) if (!emptyString(defaultApp.default_app_path)) { - goto(`/apps/get/${defaultApp.default_app_path}`) + const prefix = defaultApp.default_app_raw ? '/apps_raw/get' : '/apps/get' + goto(`${prefix}/${defaultApp.default_app_path}`) } else { goto(rd ?? '/') } @@ -341,6 +342,13 @@ btnClasses="mt-2 w-full" on:click={() => { if (saml) { + if (rd) { + try { + localStorage.setItem('rd', rd) + } catch (e) { + console.error('Could not persist redirection to local storage', e) + } + } window.location.href = saml } else { sendUserToast('No SAML login available', true) diff --git a/frontend/src/lib/components/NoMainFuncBadge.svelte b/frontend/src/lib/components/NoMainFuncBadge.svelte index 3e2a2d28bb..c72e05583c 100644 --- a/frontend/src/lib/components/NoMainFuncBadge.svelte +++ b/frontend/src/lib/components/NoMainFuncBadge.svelte @@ -5,7 +5,7 @@ {#snippet text()} - The script has no main function exported + Library script (no exported main function) {/snippet} - No main + Library diff --git a/frontend/src/lib/components/OnBehalfOfSelector.svelte b/frontend/src/lib/components/OnBehalfOfSelector.svelte index 9921972a39..55ea1a99f4 100644 --- a/frontend/src/lib/components/OnBehalfOfSelector.svelte +++ b/frontend/src/lib/components/OnBehalfOfSelector.svelte @@ -1,16 +1,25 @@ @@ -24,31 +33,39 @@ interface Props { targetWorkspace: string - targetEmail: string | undefined + /** The target value: email for flows/scripts, permissioned_as (u/username) for triggers */ + targetValue: string | undefined selected: OnBehalfOfChoice - onSelect: (choice: OnBehalfOfChoice, email?: string, username?: string) => void + /** + * Called when the user picks a choice. + * For 'custom', `details` contains both `email` and `permissionedAs` (u/{username}). + * Callers pick whichever format they need. + */ + onSelect: (choice: OnBehalfOfChoice, details?: OnBehalfOfDetails) => void kind: string canPreserve: boolean - /** The email of the custom-selected user (for display) */ - customEmail?: string | undefined + /** The value of the custom-selected user (for display) */ + customValue?: string | undefined /** When false, labels say "current" instead of "target" and modal text refers to "this workspace" */ isDeployment?: boolean } let { targetWorkspace, - targetEmail, + targetValue, selected, onSelect, kind, canPreserve, - customEmail, + customValue, isDeployment = true }: Props = $props() + const isTrigger = $derived(kind === 'trigger') + let label = $derived( - kind === 'trigger' - ? 'Set the user this will be recorded as edited by:' + isTrigger + ? 'Set the user this will be permissioned as:' : 'Set the user this will be run on behalf of:' ) @@ -70,13 +87,17 @@ // Fetch users eagerly so we can resolve usernames for display loadUsers() - function resolveUsername(email: string | undefined): string | undefined { - if (!email) return undefined - return users.find((u) => u.email === email)?.username ?? email + /** Resolve a value to a display name, always showing u/username format */ + function resolveDisplayName(value: string | undefined): string | undefined { + if (!value) return undefined + if (value.startsWith('u/') || value.startsWith('g/')) return value + const username = users.find((u) => u.email === value)?.username + return username ? `u/${username}` : value } - let targetUsername = $derived(resolveUsername(targetEmail)) - let customUsername = $derived(resolveUsername(customEmail)) + let targetDisplayName = $derived(resolveDisplayName(targetValue)) + let customDisplayName = $derived(resolveDisplayName(customValue)) + let myDisplayName = $derived($userStore?.username ? `u/${$userStore.username}` : undefined) let activeUsers = $derived(users.filter((u) => !u.disabled)) let filteredUsers = $derived( @@ -94,7 +115,7 @@ // Preselect "target" when available and user has permission to preserve $effect(() => { - if (selected === undefined && targetEmail && canPreserve) { + if (selected === undefined && targetValue && canPreserve) { onSelect('target') } }) @@ -106,34 +127,32 @@ } function selectUser(user: User) { - onSelect('custom', user.email, user.username) + onSelect('custom', { email: user.email, permissionedAs: `u/${user.username}` }) modalOpen = false } let selectedDisplayName = $derived.by(() => { - if (selected === 'target') return targetUsername - if (selected === 'me') return $userStore?.username - if (selected === 'custom') return customUsername + if (selected === 'target') return targetDisplayName + if (selected === 'me') return myDisplayName + if (selected === 'custom') return customDisplayName return undefined }) e.detail && loadUsers()}> {#snippet trigger()} - - - - {#if selectedDisplayName} - {selectedDisplayName} - {/if} - - + + + {#if selectedDisplayName} + {selectedDisplayName} + {/if} + {/snippet} {#snippet content({ close: closePopover })} -
+
{label}
- {#if targetEmail} + {#if targetValue} {/if} @@ -152,7 +171,7 @@ onclick={() => onSelect('me')} > - {$userStore?.username} + {myDisplayName} (me) @@ -166,9 +185,9 @@ openModal() }} > - {#if selected === 'custom' && customUsername} + {#if selected === 'custom' && customDisplayName} - {customUsername} + {customDisplayName} (custom) {:else} @@ -184,11 +203,15 @@
- {#if kind === 'trigger'} - Choose the user this trigger will be recorded as edited by {isDeployment ? 'in the target workspace' : 'in this workspace'}. + {#if isTrigger} + Choose the user this trigger will be permissioned as {isDeployment + ? 'in the target workspace' + : 'in this workspace'}. The selected user's permissions will be used when the trigger + fires. {:else} - Choose the user this {kind} will run on behalf of {isDeployment ? 'in the target workspace' : 'in this workspace'}. The selected - user's permissions will be used when executing. + Choose the user this {kind} will run on behalf of {isDeployment + ? 'in the target workspace' + : 'in this workspace'}. The selected user's permissions will be used when executing. {/if} selectUser(user)} >
- {user.username} + u/{user.username} {user.email}
- {#if customEmail === user.email && selected === 'custom'} + {#if selected === 'custom' && (customValue === `u/${user.username}` || customValue === user.email)} {/if} diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 70aaf6a2ee..416068bf8b 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -25,6 +25,7 @@ import Button from './common/button/Button.svelte' import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte' import ResourceGen from './copilot/ResourceGen.svelte' + import SyncResourceTypes from './SyncResourceTypes.svelte' interface Props { canSave?: boolean @@ -33,6 +34,7 @@ hidePath?: boolean onChange?: (args: { path: string; args: Record; description: string }) => void defaultValues?: Record | undefined + workspace?: string | undefined } let { @@ -41,9 +43,12 @@ path = $bindable(''), hidePath = false, onChange, - defaultValues = undefined + defaultValues = undefined, + workspace = undefined }: Props = $props() + let effectiveWorkspace = $derived(workspace ?? $workspaceStore!) + let isValid = $state(true) let jsonError = $state('') let can_write = $state(true) @@ -68,13 +73,13 @@ let rawCode: string | undefined = $state(undefined) async function initEdit() { - resourceToEdit = await ResourceService.getResource({ workspace: $workspaceStore!, path }) + resourceToEdit = await ResourceService.getResource({ workspace: effectiveWorkspace, path }) description = resourceToEdit!.description ?? '' resource_type = resourceToEdit!.resource_type args = resourceToEdit?.value ?? ({} as any) loadResourceType() can_write = - resourceToEdit.workspace_id == $workspaceStore && + resourceToEdit.workspace_id == effectiveWorkspace && canWrite(path, resourceToEdit.extra_perms ?? {}, $userStore) linkedVars = Object.entries(args) .filter(([_, v]) => typeof v == 'string' && v == `$var:${initialPath}`) @@ -92,12 +97,12 @@ export async function editResource(): Promise { if (resourceToEdit) { await ResourceService.updateResource({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, path: resourceToEdit.path, requestBody: { path, value: args, description } }) if (resourceToEdit.resource_type === 'json_schema') { - clearJsonSchemaResourceCache(resourceToEdit.path, $workspaceStore!) + clearJsonSchemaResourceCache(resourceToEdit.path, effectiveWorkspace) } sendUserToast(`Updated resource at ${path}`) dispatch('refresh', path) @@ -108,7 +113,7 @@ export async function createResource(): Promise { await ResourceService.createResource({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, requestBody: { path, value: args, description, resource_type: resource_type! } }) sendUserToast(`Updated resource at ${path}`) @@ -119,7 +124,7 @@ if (resource_type) { try { const resourceType = await ResourceService.getResourceType({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, path: resource_type }) @@ -343,10 +348,13 @@ {:else} {#if !viewJsonSchema} -

- No corresponding resource type found in your workspace for {resource_type}. Define the - value in JSON directly -

+
+

+ Resource type '{resource_type}' not found in your workspace +

+ +

Define the value in JSON directly

+
{/if} {#if !emptyString(jsonError)} - + void excludedValues?: string[] datatableAsPgResource?: boolean + workspace?: string | undefined + disableChatOffset?: boolean } let { @@ -47,9 +49,13 @@ class: className = '', onClear = undefined, excludedValues = undefined, - datatableAsPgResource = false + datatableAsPgResource = false, + workspace = undefined, + disableChatOffset = false }: Props = $props() + let effectiveWorkspace = $derived(workspace ?? $workspaceStore!) + if (initialValue && value == undefined) { value = initialValue } @@ -104,7 +110,7 @@ const resources = await Promise.all( resourceTypesToQuery.map((rt) => ResourceService.listResource({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, resourceType: rt }) ) @@ -122,7 +128,7 @@ if (datatableAsPgResource && resourceType === 'postgresql') { try { const datatables = await WorkspaceService.listDataTables({ - workspace: $workspaceStore! + workspace: effectiveWorkspace }) for (const dt of datatables) { nc.push({ @@ -155,7 +161,7 @@ let previousResourceType = untrack(() => resourceType) $effect(() => { - $workspaceStore && resourceType + effectiveWorkspace && resourceType untrack(() => { if (previousResourceType != resourceType) { previousResourceType = resourceType @@ -167,7 +173,7 @@ $effect(() => { excludedValues - if ($workspaceStore && resourceType && !disabled) { + if (effectiveWorkspace && resourceType && !disabled) { untrack(() => loadResources(resourceType)) } }) @@ -186,9 +192,13 @@ }} bind:this={appConnect} {expressOAuthSetup} + {workspace} + {disableChatOffset} /> { await loadResources(resourceType) if (e.detail) { diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index 5aee359a4a..6de1461e20 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -595,7 +595,7 @@ diff --git a/frontend/src/lib/components/S3FilePickerInner.svelte b/frontend/src/lib/components/S3FilePickerInner.svelte index 74b85bd327..9968c23180 100644 --- a/frontend/src/lib/components/S3FilePickerInner.svelte +++ b/frontend/src/lib/components/S3FilePickerInner.svelte @@ -592,10 +592,18 @@
{/if} - {#if fileListLoading === false && displayedFileKeys.length === 0} -
- No files in the workspace S3 bucket at that prefix -
+ {#if displayedFileKeys.length === 0} + {#if fileListLoading} +
+
+ Loading content +
+
+ {:else} +
+ No files in the workspace S3 bucket at that prefix +
+ {/if} {:else}
shouldHideNoInputs?: boolean compact?: boolean - linkedSecret?: string | undefined + linkedSecrets?: string[] linkedSecretCandidates?: string[] | undefined noVariablePicker?: boolean flexWrap?: boolean @@ -86,7 +90,7 @@ defaultValues = {}, shouldHideNoInputs = false, compact = false, - linkedSecret = $bindable(undefined), + linkedSecrets = $bindable([]), linkedSecretCandidates = undefined, noVariablePicker = false, flexWrap = false, @@ -295,7 +299,9 @@ class={twMerge( typeof diff[argName] === 'object' && diff[argName].diff !== 'same' && - 'bg-red-300 dark:bg-red-800 rounded-md' + 'bg-red-300 dark:bg-red-800 rounded-md', + item[SHADOW_ITEM_MARKER_PROPERTY_NAME] && + '!visible border-2 border-dashed border-blue-300 dark:border-blue-600 bg-blue-50 dark:bg-blue-900/20 rounded-md [&>*]:invisible' )} innerClass="w-full" > @@ -333,7 +339,7 @@ {variableEditor} {itemPicker} {pickForField} - password={linkedSecret == argName} + password={linkedSecrets.includes(argName)} extra={formerProperty} {showSchemaExplorer} simpleTooltip={schemaFieldTooltip[argName]} @@ -398,22 +404,24 @@ customErrorMessage={prop?.customErrorMessage} bind:properties={ () => prop?.properties, - (v) => { if (prop) prop.properties = v } + (v) => { + if (prop) prop.properties = v + } } bind:order={ () => prop?.order, - (v) => { if (prop) prop.order = v } + (v) => { + if (prop) prop.order = v + } } nestedRequired={prop?.required} itemsType={prop?.items} - disabled={disabledArgs.includes(argName) || - disabled || - prop?.disabled} + disabled={disabledArgs.includes(argName) || disabled || prop?.disabled} {compact} {variableEditor} {itemPicker} bind:pickForField - password={linkedSecret == argName} + password={linkedSecrets.includes(argName)} extra={prop} {showSchemaExplorer} simpleTooltip={schemaFieldTooltip[argName]} @@ -440,12 +448,14 @@ {#if linkedSecretCandidates?.includes(argName)}
{ if (e.detail === 'secret') { - linkedSecret = argName - } else if (linkedSecret == argName) { - linkedSecret = undefined + if (!linkedSecrets.includes(argName)) { + linkedSecrets = [...linkedSecrets, argName] + } + } else { + linkedSecrets = linkedSecrets.filter((s) => s !== argName) } }} > diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 7de0926f0a..024fe0e907 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -107,6 +107,25 @@ import { isRuleActive } from '$lib/workspaceProtectionRules.svelte' import { buildForkEditUrl } from '$lib/utils/editInFork' import OnBehalfOfSelector, { type OnBehalfOfChoice } from './OnBehalfOfSelector.svelte' + import WacExportDrawer from './scripts/WacExportDrawer.svelte' + import Modal from './common/modal/Modal.svelte' + + const WAC_ALPHA_ACK_KEY = 'windmill_wac_alpha_ack' + let wacAlphaModalOpen = $state(false) + + function showWacAlphaModalIfNeeded() { + if ( + typeof sessionStorage !== 'undefined' && + sessionStorage.getItem(WAC_ALPHA_ACK_KEY) !== 'true' + ) { + wacAlphaModalOpen = true + } + } + + function acknowledgeWacAlpha() { + sessionStorage.setItem(WAC_ALPHA_ACK_KEY, 'true') + wacAlphaModalOpen = false + } let { script = $bindable(), @@ -182,6 +201,7 @@ let editor: Editor | undefined = $state(undefined) let scriptEditor: ScriptEditor | undefined = $state(undefined) let captureTable: CaptureTable | undefined = $state(undefined) + let wacExportDrawer: WacExportDrawer | undefined = $state(undefined) // Draft triggers confirmation modal let draftTriggersModalOpen = $state(false) @@ -362,6 +382,23 @@ } if (script.content == '') { + if (template === 'wac_python') { + script.modules = { + 'helper.py': { + content: 'def main(a: str) -> str:\n return f"hello {a}"\n', + language: 'python3' + } + } + showWacAlphaModalIfNeeded() + } else if (template === 'wac_typescript') { + script.modules = { + 'helper.ts': { + content: 'export function main(a: string): string {\n return `hello ${a}`\n}\n', + language: 'bun' + } + } + showWacAlphaModalIfNeeded() + } initContent(script.language, script.kind, template) } @@ -388,7 +425,16 @@ async function initContent( language: SupportedLanguage, kind: Script['kind'] | undefined, - template: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative' | 'claudesandbox' + template: + | 'pgsql' + | 'mysql' + | 'script' + | 'docker' + | 'powershell' + | 'bunnative' + | 'claudesandbox' + | 'wac_python' + | 'wac_typescript' ) { scriptEditor?.disableCollaboration() const templateScript = await isTemplateScript() @@ -403,6 +449,7 @@ } async function handleEditScript(stay: boolean, deployMsg?: string): Promise { + scriptEditor?.flushModuleState() // Fetch latest version and fetch entire script after if needed let actual_parent_hash: string | undefined = undefined @@ -510,10 +557,10 @@ script.kind === 'preprocessor' ? 'preprocessor' : undefined ) if (script.kind === 'preprocessor') { - script.no_main_func = undefined + script.auto_kind = undefined script.has_preprocessor = undefined } else { - script.no_main_func = result?.no_main_func || undefined + script.auto_kind = result?.auto_kind || undefined script.has_preprocessor = result?.has_preprocessor || undefined } } catch (error) { @@ -554,12 +601,13 @@ timeout: script.timeout, concurrency_key: emptyString(script.concurrency_key) ? undefined : script.concurrency_key, visible_to_runner_only: script.visible_to_runner_only, - no_main_func: script.no_main_func, + auto_kind: script.auto_kind, has_preprocessor: script.has_preprocessor, deployment_message: deploymentMsg || undefined, on_behalf_of_email: script.on_behalf_of_email, preserve_on_behalf_of: preserveOnBehalfOf || undefined, - assets: script.assets + assets: script.assets, + modules: script.modules } }) @@ -592,7 +640,12 @@ if (!disableHistoryChange) { history.replaceState(history.state, '', `/scripts/edit/${script.path}`) } - if (stay || (script.no_main_func && script.kind !== 'preprocessor' && !isWorkflowAsCode(script.content, script.language))) { + if ( + stay || + (script.auto_kind === 'lib' && + script.kind !== 'preprocessor' && + !isWorkflowAsCode(script.content, script.language)) + ) { script.parent_hash = newHash sendUserToast('Deployed') } else { @@ -606,6 +659,7 @@ } async function saveDraft(forceSave = false): Promise { + scriptEditor?.flushModuleState() if (initialPath != '' && !savedScript) { return } @@ -643,10 +697,10 @@ script.kind === 'preprocessor' ? 'preprocessor' : undefined ) if (script.kind === 'preprocessor') { - script.no_main_func = undefined + script.auto_kind = undefined script.has_preprocessor = undefined } else { - script.no_main_func = result?.no_main_func || undefined + script.auto_kind = result?.auto_kind || undefined script.has_preprocessor = result?.has_preprocessor || undefined } } catch (error) { @@ -707,10 +761,11 @@ ? undefined : script.concurrency_key, visible_to_runner_only: script.visible_to_runner_only, - no_main_func: script.no_main_func, + auto_kind: script.auto_kind, has_preprocessor: script.has_preprocessor, on_behalf_of_email: script.on_behalf_of_email, - assets: script.assets + assets: script.assets, + modules: script.modules } }) } @@ -816,7 +871,7 @@ } ] : []), - ...(!script.draft_only && script.kind === 'script' && !script.no_main_func + ...(!script.draft_only && script.kind === 'script' && !script.auto_kind ? [ { label: 'Exit & See details', @@ -825,10 +880,31 @@ } } ] + : []), + ...(isWorkflowAsCode(script.content, script.language) + ? [ + { + label: 'Export as YAML/JSON', + onClick: () => { + wacExportDrawer?.open(script) + } + } + ] : []) ] : [] + if (dropdownItems.length === 0 && isWorkflowAsCode(script.content, script.language)) { + dropdownItems = [ + { + label: 'Export as YAML/JSON', + onClick: () => { + wacExportDrawer?.open(script) + } + } + ] + } + return dropdownItems.length > 0 ? dropdownItems : undefined } @@ -1165,7 +1241,10 @@
{#each langs as [label, lang] (lang)} {@const isPicked = - (lang == script.language && template != 'bunnative' && template != 'docker' && template != 'claudesandbox') || + (lang == script.language && + template != 'bunnative' && + template != 'docker' && + template != 'claudesandbox') || (template == 'bunnative' && lang == 'bunnative') || (template == 'docker' && lang == 'docker') || (template == 'claudesandbox' && lang == 'bun')} @@ -1201,7 +1280,7 @@
{/if} -
+
Template + + + + + +
{#if customUi?.settingsPanel?.metadata?.disableScriptKind !== true}
@@ -1653,9 +1783,9 @@ {#if script.on_behalf_of_email && canPreserve} → { + onSelect={(choice, details) => { onBehalfOfChoice = choice if (choice === 'me') { script.on_behalf_of_email = $userStore?.email @@ -1665,15 +1795,15 @@ script.on_behalf_of_email = originalOnBehalfOfEmail customOnBehalfOfEmail = '' preserveOnBehalfOf = true - } else if (choice === 'custom' && email) { - script.on_behalf_of_email = email - customOnBehalfOfEmail = email + } else if (choice === 'custom' && details) { + script.on_behalf_of_email = details.email + customOnBehalfOfEmail = details.email preserveOnBehalfOf = true } }} kind="script" {canPreserve} - customEmail={customOnBehalfOfEmail} + customValue={customOnBehalfOfEmail} isDeployment={false} /> {:else if script.on_behalf_of_email && !canPreserve} @@ -1948,9 +2078,35 @@ bind:hasPreprocessor bind:captureTable bind:assets={script.assets} + bind:modules={script.modules} enablePreprocessorSnippet />
{:else} Script Builder not available to operators {/if} + + + + +
+ diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index f310010c18..2690301f28 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -1,8 +1,14 @@ + +{#if $superadmin} + + {#if hubRtSync.status === 'error'} + + Error syncing resource types: {hubRtSync.error?.message ?? JSON.stringify(hubRtSync.error)} + + {/if} +{/if} diff --git a/frontend/src/lib/components/WorkerGroup.svelte b/frontend/src/lib/components/WorkerGroup.svelte index da43629294..fdb0320c2a 100644 --- a/frontend/src/lib/components/WorkerGroup.svelte +++ b/frontend/src/lib/components/WorkerGroup.svelte @@ -230,7 +230,11 @@ let workspaces: Workspace[] = $state([]) async function listWorkspaces() { - workspaces = await WorkspaceService.listWorkspacesAsSuperAdmin() + try { + workspaces = await WorkspaceService.listWorkspacesAsSuperAdmin() + } catch (e) { + console.error('Failed to list workspaces', e) + } } // Centralized permission logic @@ -291,7 +295,12 @@ workers.some(([_, pings]) => pings.some((p) => p.native_mode === true))) ) let nonNativeTags = $derived( - (nconfig?.worker_tags ?? []).filter((t) => !nativeTags.includes(t) && t !== 'flow') + (nconfig?.worker_tags ?? []).filter( + (t) => + !nativeTags.some((nt) => t === nt || t.startsWith(`${nt}-`)) && + t !== 'flow' && + !t.startsWith('flow-') + ) ) let isAutoNativeMode = $derived(name === 'native') let isNativeModeEnabled = $derived(nconfig?.native_mode === true || isAutoNativeMode) @@ -569,8 +578,8 @@ This worker group has native mode enabled but includes non-native tags: {nonNativeTags.join( ', ' - )}. Non-native jobs will be failed. This is fine if those custom tags are only used - for native language jobs. + )}. This is fine if jobs sent to those tags are native only, otherwise they will be + failed. {/if} {#if isNativeModeEnabled && nconfig?.worker_tags != undefined && !nconfig.worker_tags.includes(defaultTagPerWorkspace && workspaceTag ? `flow-${workspaceTag}` : 'flow')} diff --git a/frontend/src/lib/components/WorkflowTimeline.svelte b/frontend/src/lib/components/WorkflowTimeline.svelte index 66fbb5b6c5..2ea5ebba50 100644 --- a/frontend/src/lib/components/WorkflowTimeline.svelte +++ b/frontend/src/lib/components/WorkflowTimeline.svelte @@ -1,19 +1,48 @@ {#if flow_status}
-
-
{min ? displayDate(new Date(min), true) : ''}
{#if max && min} - {/if}
{max ? displayDate(new Date(max), true) : ''}{#if !max && min}{#if now} +
+
+
+
{min ? displayDate(new Date(min), true) : ''}
+ {#if max && min} + + {/if} +
+ {max ? displayDate(new Date(max), true) : ''} + {#if !max && min} + {#if now} {msToSec(now - min, 3)}s - {/if}{/if}
+ {/if} + + {/if} +
+
@@ -61,61 +191,272 @@
Waiting for executor
-
Execution
- {#each Object.entries(flow_status) as [k, v] (k)} -
-
- {v.name ?? k} -
- {#if min && total} - {@const scheduledFor = v?.scheduled_for - ? new Date(v?.scheduled_for).getTime() - : undefined} - {@const startedAt = v?.started_at ? new Date(v?.started_at).getTime() : undefined} - - {@const waitingLen = scheduledFor - ? startedAt - ? startedAt - scheduledFor - : now - scheduledFor - : 0} - -
- - {#if startedAt} - { + const ta = new Date(a.started_at ?? a.scheduled_for ?? 0).getTime() + const tb = new Date(b.started_at ?? b.scheduled_for ?? 0).getTime() + return ta - tb + }) as [k, v] (k)} + {@const isInlineStep = isStep(k)} + {@const isSleep = (v as any).sleep_duration_s != undefined} + {@const isApproval = (v as any).approval === true} + {@const isRunning = !flowDone && v.duration_ms == undefined && v.started_at != undefined} + {@const isDone = v.duration_ms != undefined || flowDone} + {@const isExpanded = expandedRows[k] ?? false} +
+ {#if isSleep} +
+
+ + sleep ({(v as any).sleep_duration_s}s) +
+ {:else if isApproval} + {@const selfApprovalDisabled = (v as any).self_approval_disabled === true} + {@const formSchema = (v as any).form?.schema ?? (v as any).form} + {@const hasForm = + formSchema && typeof formSchema === 'object' && Object.keys(formSchema).length > 0} + {@const canApprove = !isDone && jobId} +
+
+
+ + + {v.name ?? stepKey(k)} + + {#if !isDone} + + + waiting + + {#if canApprove} +
+ + +
+ {/if} + {:else} + {msToSec(v.duration_ms ?? 0)}s + {/if} +
+ {#if canApprove && selfApprovalDisabled && $userStore?.is_admin} +
+ Self-approval is disabled but allowed because you are an admin/owner +
+ {/if} + {#if canApprove && hasForm} +
+ {#if emptyString($enterpriseLicense)} + + {:else} + {/if}
- {/if}
+ {/if} +
+ {:else} + + + {#if isExpanded} +
+ {#if isInlineStep} + + {@const result = stepResults[stepKey(k)]} + {#if isDone && result !== undefined} +
+
Result
+
+ +
+
+ {:else} +
Step completed (no result)
+ {/if} + {:else if loadingJobs[k] && !childJobs[k]} +
+ + Loading... +
+ {:else if childJobs[k]} + {@const job = childJobs[k]} + + {#if job.logs || isRunning} +
+
Logs
+ +
+ {/if} + + + {#if isDone && job.result !== undefined} +
+
Result
+
+ +
+
+ {/if} + {:else} +
No data available
+ {/if} +
+ {/if} + {/if}
{/each} + {#if flowDone && result !== undefined} +
+ + {#if resultExpanded} +
+
+ +
+
+ {/if} +
+ {/if}
{:else} diff --git a/frontend/src/lib/components/WorkspaceDependenciesEditor.svelte b/frontend/src/lib/components/WorkspaceDependenciesEditor.svelte index ec0b342f56..7bc0351c82 100644 --- a/frontend/src/lib/components/WorkspaceDependenciesEditor.svelte +++ b/frontend/src/lib/components/WorkspaceDependenciesEditor.svelte @@ -40,6 +40,19 @@ return deps.name || `Default (${deps.language})` } + function getEditorLang(language: ScriptLang): string { + switch (language) { + case 'bun': + case 'php': + case 'powershell': + return 'json' + case 'python3': + return 'plaintext' + default: + return 'markdown' + } + } + export function getFileExtension(language: ScriptLang): string | null { switch (language) { case 'python3': @@ -50,6 +63,8 @@ // return 'go.mod' case 'php': return 'composer.json' + case 'powershell': + return 'modules.json' default: return null } @@ -113,7 +128,8 @@ { value: 'python3', label: 'Python' }, { value: 'bun', label: 'TypeScript (Bun/Bunnative)' }, // { value: 'go', label: 'Go' }, - { value: 'php', label: 'PHP' } + { value: 'php', label: 'PHP' }, + { value: 'powershell', label: 'PowerShell' } ] // Default templates for each language @@ -157,6 +173,13 @@ numpy>=1.24.0 "vlucas/phpdotenv": "^5.6", "symfony/console": "^6.4" } +}`, + + powershell: `{ + "modules": { + "PSWriteColor": "*", + "ImportExcel": "7.8.6" + } }` } @@ -497,7 +520,7 @@ numpy>=1.24.0 handleEditorChange(e.detail)} fixedOverflowWidgets={false} @@ -505,6 +528,15 @@ numpy>=1.24.0 /> {/await}
+ {#if workspaceDependencies.language === 'powershell'} +
+ JSON object with a "modules" key mapping module names to versions. Use + "*" + or null for latest version, or a specific version string to pin. These + modules are merged with script-level + Import-Module statements at runtime (workspace versions take precedence). +
+ {/if}
diff --git a/frontend/src/lib/components/apps/components/display/dbtable/DeleteRow.svelte b/frontend/src/lib/components/apps/components/display/dbtable/DeleteRow.svelte index 5d8778b01e..0e30c6b421 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/DeleteRow.svelte +++ b/frontend/src/lib/components/apps/components/display/dbtable/DeleteRow.svelte @@ -62,8 +62,8 @@ onCancel: () => { sendUserToast('Error deleting row', true) }, - onError: () => { - sendUserToast('Error updating row', true) + onError: (e) => { + sendUserToast(`Error deleting row: ${e?.message ?? e}`, true) } } ) diff --git a/frontend/src/lib/components/apps/components/display/dbtable/InsertRowRunnable.svelte b/frontend/src/lib/components/apps/components/display/dbtable/InsertRowRunnable.svelte index 6b11362f3b..fea5232c6b 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/InsertRowRunnable.svelte +++ b/frontend/src/lib/components/apps/components/display/dbtable/InsertRowRunnable.svelte @@ -59,8 +59,8 @@ onCancel: () => { sendUserToast('Error inserting row', true) }, - onError: () => { - sendUserToast('Error inserting row', true) + onError: (e) => { + sendUserToast(`Error inserting row: ${e?.message ?? e}`, true) } }) } diff --git a/frontend/src/lib/components/apps/components/display/dbtable/UpdateCell.svelte b/frontend/src/lib/components/apps/components/display/dbtable/UpdateCell.svelte index e90ea9d96b..c324d7e408 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/UpdateCell.svelte +++ b/frontend/src/lib/components/apps/components/display/dbtable/UpdateCell.svelte @@ -64,8 +64,8 @@ onCancel: () => { sendUserToast('Error updating value', true) }, - onError: () => { - sendUserToast('Error updating value', true) + onError: (e) => { + sendUserToast(`Error updating value: ${e?.message ?? e}`, true) } } ) diff --git a/frontend/src/lib/components/apps/components/display/dbtable/metadata.ts b/frontend/src/lib/components/apps/components/display/dbtable/metadata.ts index 4d970b2303..2fdb61522d 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/metadata.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/metadata.ts @@ -1,4 +1,4 @@ -import { JobService, ResourceService, type ScriptLang } from '$lib/gen' +import { JobService, ResourceService } from '$lib/gen' import { runScriptAndPollResult } from '$lib/components/jobs/utils' import type { DbInput } from '$lib/components/dbTypes' @@ -15,9 +15,18 @@ import type { DBSchema, GraphqlSchema, SQLSchema } from '$lib/stores' import { stringifyGraphqlSchema, stringifySchema } from '$lib/components/copilot/lib' import type { DbType } from '$lib/components/dbTypes' -import { getDatabaseArg } from '$lib/components/dbOps' +import { getDatabaseArg, getDbType } from '$lib/components/dbOps' import { sendUserToast } from '$lib/toast' +function makeMetadataMarker( + op: string, + payload: Record, + ducklake: string | undefined +): string { + if (ducklake) payload.ducklake = ducklake + return `-- WM_INTERNAL_DB_${op} ${JSON.stringify(payload)}` +} + export async function loadTableMetaData( input: DbInput, workspace: string | undefined, @@ -25,11 +34,26 @@ export async function loadTableMetaData( ): Promise { if (!input || !table || !workspace) return undefined - let { language, query } = await makeLoadTableMetaDataQuery(input, workspace, table) + const dbType = getDbType(input) + const language = getLanguageByResourceType(dbType) + const ducklake = input.type === 'ducklake' ? input.ducklake : undefined + const dbArg = getDatabaseArg(input) + + // MySQL needs the database name for metadata queries + let databaseName: string | undefined + if (input.type === 'database' && input.resourceType === 'mysql') { + const resourceObj = (await ResourceService.getResourceValue({ + workspace, + path: input.resourcePath + })) as any + databaseName = resourceObj?.database + } + + const content = makeMetadataMarker('LOAD_TABLE_METADATA', { table, databaseName }, ducklake) const job = await JobService.runScriptPreview({ - workspace: workspace, - requestBody: { language, content: query, args: getDatabaseArg(input) } + workspace, + requestBody: { language, content, args: dbArg } }) const maxRetries = 8 @@ -39,7 +63,7 @@ export async function loadTableMetaData( await new Promise((resolve) => setTimeout(resolve, 1000 * (attempts || 0.6))) const testResult = (await JobService.getCompletedJob({ - workspace: workspace, + workspace, id: job })) as any @@ -78,10 +102,30 @@ export async function loadAllTablesMetaData( if (!input || !workspace) return undefined try { - let { language, query } = await makeLoadTableMetaDataQuery(input, workspace, undefined) + const dbType = getDbType(input) + const dbArg = getDatabaseArg(input) + const ducklake = input.type === 'ducklake' ? input.ducklake : undefined + + // MySQL needs the database name for metadata queries + let databaseName: string | undefined + if (input.type === 'database' && input.resourceType === 'mysql') { + const resourceObj = (await ResourceService.getResourceValue({ + workspace, + path: input.resourcePath + })) as any + databaseName = resourceObj?.database + } + + const language = getLanguageByResourceType(dbType) + const content = makeMetadataMarker( + 'LOAD_TABLE_METADATA', + { table: undefined, databaseName }, + ducklake + ) + let result = (await runScriptAndPollResult({ - workspace: workspace, - requestBody: { language, content: query, args: getDatabaseArg(input) } + workspace, + requestBody: { language, content, args: dbArg } })) as ({ table_name: string; schema_name?: string } & object)[] const map: Record = {} @@ -101,241 +145,6 @@ export async function loadAllTablesMetaData( } } -async function makeLoadTableMetaDataQuery( - input: DbInput, - workspace: string, - table: string | undefined -): Promise<{ query: string; language: ScriptLang }> { - if (input.type === 'ducklake') { - const query = `ATTACH 'ducklake://${input.ducklake}' AS __ducklake__; - SELECT - COLUMN_NAME as field, - DATA_TYPE as DataType, - COLUMN_DEFAULT as DefaultValue, - false as IsPrimaryKey, - false as IsIdentity, - IS_NULLABLE as IsNullable, - false as IsEnum, - TABLE_NAME as table_name - FROM information_schema.columns c - WHERE table_catalog = '__ducklake__' AND table_schema = current_schema()` - return { query, language: 'duckdb' } - } else if (input.resourceType === 'mysql') { - const resourceObj = (await ResourceService.getResourceValue({ - workspace, - path: input.resourcePath - })) as any - const query = ` - SELECT - COLUMN_NAME as field, - COLUMN_TYPE as DataType, - COLUMN_DEFAULT as DefaultValue, - CASE WHEN COLUMN_KEY = 'PRI' THEN 1 ELSE 0 END as IsPrimaryKey, - CASE WHEN EXTRA like '%auto_increment%' THEN 'YES' ELSE 'NO' END as IsIdentity, - CASE WHEN IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable, - CASE WHEN DATA_TYPE = 'enum' THEN true ELSE false END as IsEnum${ - table - ? '' - : `, - TABLE_NAME as table_name` - } - FROM - INFORMATION_SCHEMA.COLUMNS${ - table - ? ` - WHERE - TABLE_NAME = '${table.split('.').reverse()[0]}' AND TABLE_SCHEMA = '${ - table.split('.').reverse()[1] ?? resourceObj?.database ?? '' - }'` - : ` - WHERE - TABLE_SCHEMA NOT IN ('mysql', 'performance_schema', 'information_schema', 'sys')` - } - ORDER BY - TABLE_NAME, - ORDINAL_POSITION; - ` - return { query, language: 'mysql' } - } else if (input.resourceType === 'postgresql') { - const query = ` - SELECT - a.attname as field, - pg_catalog.format_type(a.atttypid, a.atttypmod) as DataType, - (SELECT substring(pg_catalog.pg_get_expr(d.adbin, d.adrelid, true) for 128) - FROM pg_catalog.pg_attrdef d - WHERE d.adrelid = a.attrelid AND d.adnum = a.attnum AND a.atthasdef) as DefaultValue, - (SELECT CASE WHEN i.indisprimary THEN true ELSE 'NO' END - FROM pg_catalog.pg_class tbl, pg_catalog.pg_class idx, pg_catalog.pg_index i, pg_catalog.pg_attribute att - WHERE tbl.oid = a.attrelid AND idx.oid = i.indexrelid AND att.attrelid = tbl.oid - AND i.indrelid = tbl.oid AND att.attnum = any(i.indkey) AND att.attname = a.attname LIMIT 1) as IsPrimaryKey, - CASE a.attidentity - WHEN 'd' THEN 'By Default' - WHEN 'a' THEN 'Always' - ELSE 'No' - END as IsIdentity, - CASE a.attnotnull - WHEN false THEN 'YES' - ELSE 'NO' - END as IsNullable, - (SELECT true - FROM pg_catalog.pg_enum e - WHERE e.enumtypid = a.atttypid FETCH FIRST ROW ONLY) as IsEnum${ - table - ? '' - : `, - ns.nspname AS schema_name, - c.relname AS table_name` - } - FROM pg_catalog.pg_attribute a${ - table - ? ` - WHERE a.attrelid = (SELECT c.oid FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid WHERE relname = '${ - table.split('.').reverse()[0] - }' AND ns.nspname = '${table.split('.').reverse()[1] ?? 'public'}') - AND a.attnum > 0 AND NOT a.attisdropped - ` - : ` - JOIN pg_catalog.pg_class c ON a.attrelid = c.oid - JOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid - WHERE c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped - AND ns.nspname != 'pg_catalog' AND ns.nspname != 'information_schema'` - } - ORDER BY ${table ? 'a.attnum' : 'ns.nspname, c.relname, a.attnum'}; - - ` - return { query, language: 'postgresql' } - } else if (input.resourceType === 'ms_sql_server') { - const query = ` - SELECT - c.COLUMN_NAME as field, - c.DATA_TYPE as DataType, - c.COLUMN_DEFAULT as DefaultValue, - CASE WHEN COLUMNPROPERTY(OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME), c.COLUMN_NAME, 'IsIdentity') = 1 THEN 'By Default' ELSE 'No' END as IsIdentity, - CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END as IsPrimaryKey, - CASE WHEN c.IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable, - CASE WHEN c.DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum, - dc.name as default_constraint_name${ - table - ? '' - : `, - c.TABLE_NAME as table_name` - } -FROM - INFORMATION_SCHEMA.COLUMNS c - LEFT JOIN ( - SELECT - ku.TABLE_SCHEMA, - ku.TABLE_NAME, - ku.COLUMN_NAME - FROM - INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc - INNER JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE ku - ON tc.CONSTRAINT_TYPE = 'PRIMARY KEY' - AND tc.CONSTRAINT_NAME = ku.CONSTRAINT_NAME - AND tc.TABLE_SCHEMA = ku.TABLE_SCHEMA - AND tc.TABLE_NAME = ku.TABLE_NAME - ) pk ON c.TABLE_SCHEMA = pk.TABLE_SCHEMA - AND c.TABLE_NAME = pk.TABLE_NAME - AND c.COLUMN_NAME = pk.COLUMN_NAME - LEFT JOIN sys.default_constraints dc - ON dc.parent_object_id = OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME) - AND dc.parent_column_id = COLUMNPROPERTY(OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME), c.COLUMN_NAME, 'ColumnId')${ - table - ? ` -WHERE - c.TABLE_NAME = '${table}'` - : '' - } -ORDER BY - c.ORDINAL_POSITION; - ` - return { query, language: 'mssql' } - } else if ( - input.resourceType === 'snowflake' || - (input.resourceType as any) === 'snowflake_oauth' - ) { - const query = ` - select COLUMN_NAME as field, - DATA_TYPE as DataType, - COLUMN_DEFAULT as DefaultValue, - CASE WHEN COLUMN_DEFAULT like 'AUTOINCREMENT%' THEN 'By Default' ELSE 'No' END as IsIdentity, - 0 as IsPrimaryKey, -- a one-query solution is not trivial, we will use SHOW PRIMARY KEYS separately - CASE WHEN IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable, - CASE WHEN DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum${ - table - ? '' - : `, - table_name as table_name, - table_schema as schema_name` - } - from information_schema.columns${ - table - ? ` - where table_name = '${table.split('.').reverse()[0]}' and table_schema = '${ - table.split('.').reverse()[1] ?? 'PUBLIC' - }'` - : "\nwhere table_schema <> 'INFORMATION_SCHEMA'\n" - } - order by ORDINAL_POSITION; - ` - return { query, language: 'snowflake' } - } else if (input.resourceType === 'bigquery') { - if (table) { - const query = `SELECT - c.COLUMN_NAME as field, - DATA_TYPE as DataType, - CASE WHEN COLUMN_DEFAULT = 'NULL' THEN '' ELSE COLUMN_DEFAULT END as DefaultValue, - CASE WHEN constraint_name is not null THEN true ELSE false END as IsPrimaryKey, - 'No' as IsIdentity, - IS_NULLABLE as IsNullable, - false as IsEnum -FROM - ${table.split('.')[0]}.INFORMATION_SCHEMA.COLUMNS c - LEFT JOIN - ${table.split('.')[0]}.INFORMATION_SCHEMA.KEY_COLUMN_USAGE p - on c.table_name = p.table_name AND c.column_name = p.COLUMN_NAME -WHERE - c.TABLE_NAME = '${table.split('.')[1]}' -order by c.ORDINAL_POSITION;` - return { query, language: 'bigquery' } - } else { - const query = `import { BigQuery } from '@google-cloud/bigquery@7.5.0'; -export async function main(database: bigquery) { -const bq = new BigQuery({ - credentials: database -}) -const [datasets] = await bq.getDatasets(); -if (!datasets) return {} -const schema = {} as any -let queries = datasets.map(dataset => \` - (SELECT - c.COLUMN_NAME as field, - '\${dataset.id}' as schema_name, - c.TABLE_NAME as table_name, - DATA_TYPE as DataType, - CASE WHEN COLUMN_DEFAULT = 'NULL' THEN '' ELSE COLUMN_DEFAULT END as DefaultValue, - CASE WHEN constraint_name is not null THEN true ELSE false END as IsPrimaryKey, - 'No' as IsIdentity, - IS_NULLABLE as IsNullable, - false as IsEnum -FROM - \\\`\${dataset.id}\\\`.INFORMATION_SCHEMA.COLUMNS c - LEFT JOIN - \\\`\${dataset.id}\\\`.INFORMATION_SCHEMA.KEY_COLUMN_USAGE p - on c.table_name = p.table_name AND c.column_name = p.COLUMN_NAME -ORDER BY c.ORDINAL_POSITION)\` -) -let query = queries.join('\\nUNION ALL \\n') -const [rows] = await bq.query(query) -return rows -}` - return { query, language: 'bun' } - } - } else { - throw new Error('Unsupported database type:' + input.resourceType) - } -} - type SnowflakeShowPrimaryKeysResult = { column_name: string database_name: string @@ -379,12 +188,15 @@ async function fetchSnowflakePrimaryKeys( dbArg: any, tableKey?: string ): Promise { + const payload: Record = {} + if (tableKey) payload.table = tableKey + const content = makeMetadataMarker('SNOWFLAKE_PRIMARY_KEYS', payload, undefined) return (await JobService.runScriptPreviewAndWaitResult({ workspace, requestBody: { language: 'snowflake', args: dbArg, - content: tableKey ? `SHOW PRIMARY KEYS IN TABLE ${tableKey}` : 'SHOW PRIMARY KEYS IN ACCOUNT' + content } })) as SnowflakeShowPrimaryKeysResult[] } diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/count.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/count.ts index 3a7bf66a62..dc422f48e6 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/queries/count.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/count.ts @@ -1,3 +1,12 @@ +/** + * LEGACY: These query builders generate full SQL on the frontend. + * They exist only for backwards compatibility with Database Studio (dbexplorercomponent) apps + * whose policies were generated with expanded SQL digests. + * + * New code (Database Manager) should use WM_INTERNAL_DB markers instead, + * which are expanded server-side by the Rust query_builders module. + * See: dbOps.ts → dbTableOpsWithPreviewScripts() + */ import type { AppInput, RunnableByName } from '$lib/components/apps/inputType' import { wrapDucklakeQuery } from '../../../../../ducklake' import type { DbType, DbInput } from '$lib/components/dbTypes' diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/delete.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/delete.ts index a6ded0f727..3c6419634b 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/queries/delete.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/delete.ts @@ -1,3 +1,12 @@ +/** + * LEGACY: These query builders generate full SQL on the frontend. + * They exist only for backwards compatibility with Database Studio (dbexplorercomponent) apps + * whose policies were generated with expanded SQL digests. + * + * New code (Database Manager) should use WM_INTERNAL_DB markers instead, + * which are expanded server-side by the Rust query_builders module. + * See: dbOps.ts → dbTableOpsWithPreviewScripts() + */ import type { AppInput, RunnableByName } from '$lib/components/apps/inputType' import type { DbType, DbInput } from '$lib/components/dbTypes' import { wrapDucklakeQuery } from '../../../../../ducklake' diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/insert.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/insert.ts index 9983b7c8e8..7a79a3cbad 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/queries/insert.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/insert.ts @@ -1,3 +1,12 @@ +/** + * LEGACY: These query builders generate full SQL on the frontend. + * They exist only for backwards compatibility with Database Studio (dbexplorercomponent) apps + * whose policies were generated with expanded SQL digests. + * + * New code (Database Manager) should use WM_INTERNAL_DB markers instead, + * which are expanded server-side by the Rust query_builders module. + * See: dbOps.ts → dbTableOpsWithPreviewScripts() + */ import type { AppInput } from '$lib/components/apps/inputType' import { wrapDucklakeQuery } from '../../../../../ducklake' import type { DbType, DbInput } from '$lib/components/dbTypes' diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/relationalKeys.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/relationalKeys.ts index 1bda320bd6..ad42728d44 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/queries/relationalKeys.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/relationalKeys.ts @@ -275,7 +275,7 @@ function makeSnowflakeForeignKeysQuery(tableName: string, schemaName: string): s * pk_database_name, pk_schema_name, pk_table_name, pk_column_name, key_sequence, * update_rule, delete_rule, fk_name, pk_name, deferrability */ -function transformSnowflakeForeignKeys(snowflakeResults: any[]): RawForeignKey[] { +export function transformSnowflakeForeignKeys(snowflakeResults: any[]): RawForeignKey[] { if (!snowflakeResults || !Array.isArray(snowflakeResults)) { return [] } diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/select.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/select.ts index 47b548c9d6..737dfdc329 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/queries/select.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/select.ts @@ -1,3 +1,12 @@ +/** + * LEGACY: These query builders generate full SQL on the frontend. + * They exist only for backwards compatibility with Database Studio (dbexplorercomponent) apps + * whose policies were generated with expanded SQL digests. + * + * New code (Database Manager) should use WM_INTERNAL_DB markers instead, + * which are expanded server-side by the Rust query_builders module. + * See: dbOps.ts → dbTableOpsWithPreviewScripts() + */ import type { AppInput, RunnableByName } from '$lib/components/apps/inputType' import { wrapDucklakeQuery } from '../../../../../ducklake' import type { DbType, DbInput } from '$lib/components/dbTypes' diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/update.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/update.ts index 53aad833d4..b57fa4989b 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/queries/update.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/update.ts @@ -1,3 +1,12 @@ +/** + * LEGACY: These query builders generate full SQL on the frontend. + * They exist only for backwards compatibility with Database Studio (dbexplorercomponent) apps + * whose policies were generated with expanded SQL digests. + * + * New code (Database Manager) should use WM_INTERNAL_DB markers instead, + * which are expanded server-side by the Rust query_builders module. + * See: dbOps.ts → dbTableOpsWithPreviewScripts() + */ import type { AppInput, RunnableByName } from '$lib/components/apps/inputType' import { wrapDucklakeQuery } from '../../../../../ducklake' import type { DbInput, DbType } from '$lib/components/dbTypes' diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index 5d5484a544..649e67ef0d 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -54,10 +54,9 @@ } = $props() let isDeployer = $derived($userStore?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false) - let canPreserve = $derived( - !!$userStore?.is_admin || !!$userStore?.is_super_admin || isDeployer - ) + let canPreserve = $derived(!!$userStore?.is_admin || !!$userStore?.is_super_admin || isDeployer) let savedOnBehalfOfEmail = $derived(savedApp?.policy?.on_behalf_of_email) + let savedOnBehalfOf = $derived(savedApp?.policy?.on_behalf_of) let onBehalfOfChoice: OnBehalfOfChoice = $state(undefined) let customOnBehalfOfEmail: string = $state('') let dirtyCustomPath = $state(false) @@ -197,12 +196,13 @@ {#if canPreserve}
- Because you are either an admin or part of the {WM_DEPLOYERS_GROUP} group, you can select another user to run this app on behalf of. Once deployed the app will be run on behalf of + Because you are either an admin or part of the {WM_DEPLOYERS_GROUP} group, you can select another + user to run this app on behalf of. Once deployed the app will be run on behalf of { + onSelect={(choice, details) => { onBehalfOfChoice = choice if (choice === 'me') { policy.on_behalf_of_email = $userStore?.email @@ -211,25 +211,25 @@ preserveOnBehalfOf = false } else if (choice === 'target') { policy.on_behalf_of_email = savedOnBehalfOfEmail + policy.on_behalf_of = savedOnBehalfOf customOnBehalfOfEmail = '' preserveOnBehalfOf = true - } else if (choice === 'custom' && email) { - policy.on_behalf_of_email = email - policy.on_behalf_of = username ? `u/${username}` : undefined - customOnBehalfOfEmail = email + } else if (choice === 'custom' && details) { + policy.on_behalf_of_email = details.email + policy.on_behalf_of = details.permissionedAs + customOnBehalfOfEmail = details.email preserveOnBehalfOf = true } }} kind="app" {canPreserve} - customEmail={customOnBehalfOfEmail} + customValue={customOnBehalfOfEmail} isDeployment={false} />
{/if} -
{#if !hideSecretUrl} diff --git a/frontend/src/lib/components/apps/editor/appUtilsS3.ts b/frontend/src/lib/components/apps/editor/appUtilsS3.ts index 5e35f1a4bd..f9811edd76 100644 --- a/frontend/src/lib/components/apps/editor/appUtilsS3.ts +++ b/frontend/src/lib/components/apps/editor/appUtilsS3.ts @@ -153,8 +153,8 @@ export function computeS3FileViewerPolicy(config: RichConfigurations) { } else if ( config.source.type === 'static' && typeof config.source.value === 'string' && - ((config.sourceKind.type === 'static' && - config.sourceKind.value === 's3 (workspace storage)') || + ((config.sourceKind?.type === 'static' && + config.sourceKind?.value === 's3 (workspace storage)') || config.source.value.startsWith('s3://')) ) { return { diff --git a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptsPanel.svelte b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptsPanel.svelte index 9acb196b46..0e77fd8b8e 100644 --- a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptsPanel.svelte +++ b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptsPanel.svelte @@ -1,6 +1,6 @@ @@ -58,7 +54,7 @@ > {#if type === 'textarea'} + {:else if note} + +
{}) : undefined} + > + +
+ {:else} + +
{}) : undefined} + > + Double click to edit the note +
+ {/if} +
+
diff --git a/frontend/src/lib/components/graph/GroupOverlay.svelte b/frontend/src/lib/components/graph/GroupOverlay.svelte new file mode 100644 index 0000000000..c15095bfb4 --- /dev/null +++ b/frontend/src/lib/components/graph/GroupOverlay.svelte @@ -0,0 +1,90 @@ + + +{#each groups as group (groupKey(group))} + {@const bounds = groupBoundsMap[groupKey(group)]} + {#if bounds} + +
+
+ {/if} +{/each} diff --git a/frontend/src/lib/components/graph/MiniFlowGraph.svelte b/frontend/src/lib/components/graph/MiniFlowGraph.svelte index fdb73745df..ab0c5042e8 100644 --- a/frontend/src/lib/components/graph/MiniFlowGraph.svelte +++ b/frontend/src/lib/components/graph/MiniFlowGraph.svelte @@ -1,6 +1,12 @@ - -{#if noteEditorContext?.noteEditor && selectedNodeIds.length > 1} - - {@render children()} - -{/if} diff --git a/frontend/src/lib/components/graph/NoteColorPicker.svelte b/frontend/src/lib/components/graph/NoteColorPicker.svelte index d17adb9317..ff3a1a276c 100644 --- a/frontend/src/lib/components/graph/NoteColorPicker.svelte +++ b/frontend/src/lib/components/graph/NoteColorPicker.svelte @@ -10,7 +10,11 @@ isOpen?: boolean } - let { selectedColor, onColorChange, isOpen = $bindable(false) }: Props = $props() + let { + selectedColor, + onColorChange, + isOpen = $bindable(false) + }: Props = $props() import { ViewportPortal, type Node } from '@xyflow/svelte' import { calculateNodesBoundsWithOffset } from './util' - import { StickyNote, Move, Copy, Trash2, EllipsisVertical } from 'lucide-svelte' + import { Move, Copy, Trash2, EllipsisVertical, Group } from 'lucide-svelte' import { Button } from '../common' import DropdownV2 from '../DropdownV2.svelte' - import { getNoteEditorContext } from './noteEditor.svelte' + import { getGroupEditorContext } from './groupEditor.svelte' import { getGraphContext } from './graphContext' import MoveHandleButton from './MoveHandleButton.svelte' import { tick } from 'svelte' @@ -36,18 +36,19 @@ let resolvedCount = $derived(resolvedModuleIds.length) - // Get NoteEditor context for group note creation - const noteEditorContext = getNoteEditorContext() + // Get GroupEditor context for group creation + const groupEditorContext = getGroupEditorContext() // Get Graph context for clearFlowSelection function and moveManager const graphContext = getGraphContext() const moveManager = graphContext?.moveManager - function handleAddGroupNote() { - if (selectedNodes.length > 0 && noteEditorContext?.noteEditor && graphContext) { - // Create the group note first - noteEditorContext.noteEditor.createGroupNote(selectedNodes) + let canCreateGroup = $derived(groupEditorContext?.canCreateGroup.val ?? false) + + function handleAddGroup() { + if (selectedNodes.length > 0 && groupEditorContext?.groupEditor && graphContext) { + const flowNodes = graphContext.getFlowNodes?.() ?? [] + groupEditorContext.groupEditor.createGroup(selectedNodes, flowNodes) - // Wait for next tick to ensure DOM updates tick().then(() => { graphContext?.clearFlowSelection?.() graphContext?.selectionManager.clearSelection() @@ -74,13 +75,13 @@ shortcut: isMac() ? '⌫' : 'Del', action: () => onDeleteSelected?.() }, - ...(noteEditorContext?.noteEditor + ...(groupEditorContext?.groupEditor ? [ { - displayName: 'Add note', - icon: StickyNote, - separatorTop: true, - action: handleAddGroupNote + displayName: 'Create group', + icon: Group, + action: handleAddGroup, + disabled: !canCreateGroup } ] : []) diff --git a/frontend/src/lib/components/graph/compoundLayout.ts b/frontend/src/lib/components/graph/compoundLayout.ts index 4bf3ddebda..c47aa62584 100644 --- a/frontend/src/lib/components/graph/compoundLayout.ts +++ b/frontend/src/lib/components/graph/compoundLayout.ts @@ -1,5 +1,6 @@ import { sugiyama, dagStratify, coordCenter, decrossTwoLayer, decrossOpt } from 'd3-dag' import { NODE } from './util' +import { GROUP_HEADER_HEIGHT } from './groupEditor.svelte' type LayoutNode = { id: string @@ -14,7 +15,7 @@ type LayoutConstants = { } type CompoundGroup = { - type: 'branch' | 'loop' + type: 'branch' | 'loop' | 'group' headId: string endId: string branches: { @@ -27,9 +28,12 @@ type LayoutResult = { positions: Map bbox: { width: number; height: number } contentMinX: number + groupDimensions?: Map } const LOOP_INDENT = 25 +export const GROUP_PADDING = 16 +export const GROUP_TOP_PADDING = 32 /** * Detect compound groups from a flat list of node IDs. @@ -83,6 +87,18 @@ function detectGroups( endId: id, branches: [{ labelId: `${baseId}-start`, innerIds }] }) + } else if (baseId.startsWith('group:')) { + // Group pattern: group:{groupId} head + group:{groupId}-end + // Body is everything reachable from head to end + const innerIds = findInnerIds(baseId, id, nodeIds, childrenMap) + if (innerIds.length > 0) { + groups.push({ + type: 'group', + headId: baseId, + endId: id, + branches: [{ labelId: innerIds[0], innerIds: innerIds.slice(1) }] + }) + } } } @@ -230,6 +246,29 @@ function runSugiyama( * 5. Run sugiyama on the simplified graph * 6. Expand wrapper positions back to absolute positions */ +/** + * Build nodeSizes map for sugiyama from nodeExtraSpace. + * Each node's effective height = top + NODE.height + bottom. + */ +function buildNodeSizes( + nodeIds: string[], + constants: LayoutConstants, + nodeExtraSpace?: Map +): Map | undefined { + if (!nodeExtraSpace || nodeExtraSpace.size === 0) return undefined + const sizes = new Map() + for (const id of nodeIds) { + const extra = nodeExtraSpace.get(id) + if (extra && (extra.top > 0 || extra.bottom > 0 || extra.left > 0 || extra.right > 0)) { + sizes.set(id, { + width: constants.nodeWidth + extra.left + extra.right, + height: constants.nodeHeight + extra.top + extra.bottom + }) + } + } + return sizes.size > 0 ? sizes : undefined +} + const MAX_RECURSION_DEPTH = 50 function layoutLevel( @@ -237,7 +276,8 @@ function layoutLevel( allNodes: Map, constants: LayoutConstants, childrenMap: Map, - depth: number = 0 + depth: number = 0, + nodeExtraSpace?: Map ): LayoutResult { const positions = new Map() const nodeIdSet = new Set(nodeIds) @@ -256,8 +296,15 @@ function layoutLevel( const n = allNodes.get(id)! return { id, parentIds: (n.parentIds ?? []).filter((pid) => nodeIdSet.has(pid)) } }) - const result = runSugiyama(flatNodes, constants) + const extraSizes = buildNodeSizes( + flatNodes.map((n) => n.id), + constants, + nodeExtraSpace + ) + const result = runSugiyama(flatNodes, constants, extraSizes) for (const [id, pos] of result.positions) { + const extra = nodeExtraSpace?.get(id) + if (extra) pos.y += extra.top positions.set(id, pos) } return { positions, bbox: { width: result.width, height: result.height }, contentMinX: 0 } @@ -322,7 +369,14 @@ function layoutLevel( const branchNodeIds = [branch.labelId, ...branch.innerIds] // Find sub-groups within this branch - const result = layoutLevel(branchNodeIds, allNodes, constants, childrenMap, depth + 1) + const result = layoutLevel( + branchNodeIds, + allNodes, + constants, + childrenMap, + depth + 1, + nodeExtraSpace + ) branchLayouts.push({ labelId: branch.labelId, @@ -349,6 +403,16 @@ function layoutLevel( maxBranchHeight = Math.max(0, ...branchLayouts.map((bl) => bl.bbox.height)) // head row + branch content + end row wrapperHeight = rowHeight + maxBranchHeight + rowHeight + } else if (group.type === 'group') { + // Group: body is centered with padding on all sides + const bodyWidth = branchLayouts[0]?.bbox.width ?? constants.nodeWidth + const bodyHeight = branchLayouts[0]?.bbox.height ?? 0 + wrapperWidth = Math.max(bodyWidth + GROUP_PADDING * 2, constants.nodeWidth) + maxBranchHeight = bodyHeight + const headExtra = nodeExtraSpace?.get(group.headId) + const groupHeadRow = GROUP_HEADER_HEIGHT + (headExtra?.bottom ?? 0) + GROUP_TOP_PADDING + // head row + body + bottom padding + wrapperHeight = groupHeadRow + bodyHeight + GROUP_PADDING } else { // Loop: body is indented const bodyWidth = branchLayouts[0]?.bbox.width ?? constants.nodeWidth @@ -395,13 +459,30 @@ function layoutLevel( } // Step 5: Run sugiyama on flattened nodes - const sugResult = runSugiyama(flatNodes, constants, wrapperSizes) + // Merge wrapperSizes with nodeExtraSpace-derived sizes for non-group nodes + const extraSizes = buildNodeSizes( + flatNodes.map((n) => n.id), + constants, + nodeExtraSpace + ) + const mergedSizes = new Map() + if (extraSizes) { + for (const [id, size] of extraSizes) mergedSizes.set(id, size) + } + for (const [id, size] of wrapperSizes) mergedSizes.set(id, size) + const sugResult = runSugiyama( + flatNodes, + constants, + mergedSizes.size > 0 ? mergedSizes : undefined + ) // Step 6: Resolve absolute positions // First, set positions for regular (non-group) nodes + // Apply per-node y-offset from nodeExtraSpace so decorations above have room for (const [nid, pos] of sugResult.positions) { if (groupByHeadId.has(nid)) continue // Handle groups separately - positions.set(nid, { x: pos.x, y: pos.y }) + const extra = nodeExtraSpace?.get(nid) + positions.set(nid, { x: pos.x, y: pos.y + (extra?.top ?? 0) }) } // Now expand group wrappers into absolute positions @@ -411,9 +492,15 @@ function layoutLevel( const rowHeight = constants.nodeHeight + constants.gapV const isBranch = gl.group.type === 'branch' + const isGroup = gl.group.type === 'group' // Position the head node at the top-center of the wrapper - positions.set(headId, { x: wrapperPos.x, y: wrapperPos.y }) + // Apply extra top padding so decorations above the head node have room + const headExtra = nodeExtraSpace?.get(headId) + positions.set(headId, { + x: wrapperPos.x, + y: wrapperPos.y + (headExtra?.top ?? 0) + }) if (isBranch) { // Reuse cached branchWidths and totalWidth @@ -441,6 +528,26 @@ function layoutLevel( x: wrapperPos.x, y: wrapperPos.y + rowHeight + maxBranchHeight + constants.gapV }) + } else if (isGroup) { + // Group: body is centered within wrapper (no x offset) + const headExtra = nodeExtraSpace?.get(gl.group.headId) + const groupHeadRow = GROUP_HEADER_HEIGHT + (headExtra?.bottom ?? 0) + GROUP_TOP_PADDING + const bl = gl.branchLayouts[0] + if (bl) { + for (const [innerNodeId, innerPos] of bl.result.positions) { + positions.set(innerNodeId, { + x: wrapperPos.x + innerPos.x, + y: wrapperPos.y + groupHeadRow + innerPos.y + }) + } + } + + // Position end node below body + const bodyHeight = bl?.bbox.height ?? 0 + positions.set(gl.group.endId, { + x: wrapperPos.x, + y: wrapperPos.y + groupHeadRow + bodyHeight + GROUP_PADDING + }) } else { // Loop: position start, body, and end const bl = gl.branchLayouts[0] @@ -463,16 +570,34 @@ function layoutLevel( } } + // Collect group dimensions from this level and child layouts + const groupDimensions = new Map() + for (const [headId, gl] of groupLayouts) { + groupDimensions.set(headId, { width: gl.wrapperWidth, height: gl.wrapperHeight }) + // Propagate child groupDimensions from recursive branch layouts + for (const bl of gl.branchLayouts) { + if (bl.result.groupDimensions) { + for (const [childId, dims] of bl.result.groupDimensions) { + groupDimensions.set(childId, dims) + } + } + } + } + // Compute overall bbox (nodes + group wrapper extents) let minX = Infinity let maxX = -Infinity let minY = Infinity let maxY = -Infinity - for (const pos of positions.values()) { - minX = Math.min(minX, pos.x - constants.nodeWidth / 2) - maxX = Math.max(maxX, pos.x + constants.nodeWidth / 2) - minY = Math.min(minY, pos.y) - maxY = Math.max(maxY, pos.y + constants.nodeHeight) + for (const [nid, pos] of positions) { + // Group end nodes are zero-height markers — skip them + if (nid.startsWith('group:') && nid.endsWith('-end')) continue + const extra = nodeExtraSpace?.get(nid) + minX = Math.min(minX, pos.x - constants.nodeWidth / 2 - (extra?.left ?? 0)) + maxX = Math.max(maxX, pos.x + constants.nodeWidth / 2 + (extra?.right ?? 0)) + // Account for top decoration space above the node + minY = Math.min(minY, pos.y - (extra?.top ?? 0)) + maxY = Math.max(maxY, pos.y + constants.nodeHeight + (extra?.bottom ?? 0)) } // Account for group wrapper extents in bbox (e.g. LOOP_INDENT makes wrappers wider than nodes) for (const [headId, gl] of groupLayouts) { @@ -492,7 +617,12 @@ function layoutLevel( width: Math.max(bboxWidth, constants.nodeWidth), height: Math.max(bboxHeight, 0) } - return { positions, bbox: finalBbox, contentMinX } + return { + positions, + bbox: finalBbox, + contentMinX, + groupDimensions: groupDimensions.size > 0 ? groupDimensions : undefined + } } /** @@ -500,10 +630,16 @@ function layoutLevel( * * Takes the flat list of nodes and edges from graphBuilder and produces * absolute positions that account for compound structure (branches, loops). + * + * nodeExtraSpace: per-node top/bottom/left/right padding that should be allocated in layout. + * After layout, each node's y is shifted down by its top padding so decorations + * (assets, AI tools, group headers) have room above. Left/right padding widens the + * column allocated to the node so neighbors are pushed further away. */ export function compoundLayout( nodes: { id: string; parentIds?: string[] }[], - constants?: Partial + constants?: Partial, + nodeExtraSpace?: Map ): LayoutResult { const c: LayoutConstants = { nodeWidth: constants?.nodeWidth ?? NODE.width, @@ -528,7 +664,7 @@ export function compoundLayout( } const nodeIds = nodes.map((n) => n.id) - const result = layoutLevel(nodeIds, allNodes, c, childrenMap) + const result = layoutLevel(nodeIds, allNodes, c, childrenMap, 0, nodeExtraSpace) // Shift positions so minX=0 (left-aligned). // FlowGraphV2 centers with: xCenter = viewport/2 - bbox.width/2 diff --git a/frontend/src/lib/components/graph/flowStructure.test.ts b/frontend/src/lib/components/graph/flowStructure.test.ts new file mode 100644 index 0000000000..fce5071e80 --- /dev/null +++ b/frontend/src/lib/components/graph/flowStructure.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, vi } from 'vitest' + +// Mock modules that transitively import CSS/Monaco +vi.mock('monaco-editor', () => ({})) +vi.mock('@xyflow/svelte', () => ({})) +vi.mock('./renderers/nodes/AssetNode.svelte', () => ({ + assetDisplaysAsOutputInFlowGraph: () => false +})) +vi.mock('../modulesTest.svelte', () => ({})) + +import type { GraphGroup } from './groupEditor.svelte' +import type { FlowModule } from '$lib/gen' +import { + buildStructureTree, + flattenStructureIds, + deriveGroupsFromStructure, + collectLeafIds, + findInStructure +} from './flowStructure' + +function makeModule(id: string): FlowModule { + return { + id, + value: { type: 'rawscript', content: '', language: 'python3' } as any + } as FlowModule +} + +function makeBranchAll(id: string, branchInnerIds: string[][]): FlowModule { + return { + id, + value: { + type: 'branchall', + branches: branchInnerIds.map((ids) => ({ modules: ids.map((iid) => makeModule(iid)) })) + } as any + } as FlowModule +} + +function makeForloop(id: string, innerIds: string[]): FlowModule { + return { + id, + value: { + type: 'forloopflow', + modules: innerIds.map((iid) => makeModule(iid)), + iterator: { type: 'javascript', expr: '' } + } as any + } as FlowModule +} + +function makeGroup( + id: string, + start_id: string, + end_id: string, + moduleIds: string[] = [] +): GraphGroup { + return { id, start_id, end_id, moduleIds } +} + +describe('buildStructureTree', () => { + const modules = [makeModule('a'), makeModule('b'), makeModule('c')] + + it('builds structure tree for a valid group', () => { + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])] + const result = buildStructureTree(modules, groups) + // Should have a group node + the remaining leaf 'c' + expect(result).toHaveLength(2) + expect(result[0].kind).toBe('group') + expect(result[0].id).toBe('g1') + expect(result[0].branches[0].children).toHaveLength(2) + expect(result[1].kind).toBe('leaf') + expect(result[1].id).toBe('c') + }) + + it('throws on duplicate group IDs', () => { + const groups = [makeGroup('g1', 'a', 'a', ['a']), makeGroup('g1', 'b', 'c', ['b', 'c'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/duplicate group id.*g1/i) + }) + + it('throws on inverted range (start_id after end_id)', () => { + const groups = [makeGroup('g1', 'c', 'a', ['a', 'b', 'c'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/inverted range/i) + }) + + it('throws on partially overlapping groups', () => { + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b']), makeGroup('g2', 'b', 'c', ['b', 'c'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/overlap without nesting/i) + }) + + it('throws when group start_id is a virtual node (Input)', () => { + const groups = [makeGroup('g1', 'Input', 'b', ['a', 'b'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/virtual node/i) + }) + + it('throws when group end_id is a virtual node (Result)', () => { + const groups = [makeGroup('g1', 'a', 'Result', ['a', 'b', 'c'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/virtual node/i) + }) + + it('throws when group references Trigger', () => { + const groups = [makeGroup('g1', 'Trigger', 'c', ['a', 'b', 'c'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/virtual node/i) + }) + + it('allows fully nested groups', () => { + const mods = [makeModule('a'), makeModule('b'), makeModule('c'), makeModule('d')] + const groups = [ + makeGroup('outer', 'a', 'd', ['a', 'b', 'c', 'd']), + makeGroup('inner', 'b', 'c', ['b', 'c']) + ] + const result = buildStructureTree(mods, groups) + expect(result).toHaveLength(1) // outer group contains everything + expect(result[0].kind).toBe('group') + // Inner group should be nested + const outerChildren = result[0].branches[0].children + expect(outerChildren).toHaveLength(3) // a, inner-group, d + expect(outerChildren[1].kind).toBe('group') + expect(outerChildren[1].id).toBe('inner') + }) + + it('handles empty modules', () => { + const result = buildStructureTree([], []) + expect(result).toHaveLength(0) + }) + + it('handles container modules (forloop)', () => { + const mods = [makeForloop('loop', ['x', 'y']), makeModule('c')] + const result = buildStructureTree(mods, []) + expect(result).toHaveLength(2) + expect(result[0].kind).toBe('forloopflow') + expect(result[0].branches).toHaveLength(1) + expect(result[0].branches[0].children).toHaveLength(2) + expect(result[0].branches[0].children[0].id).toBe('x') + }) + + it('handles groups inside containers', () => { + const mods = [makeForloop('loop', ['x', 'y', 'z'])] + const groups = [makeGroup('g1', 'x', 'y', ['x', 'y'])] + const result = buildStructureTree(mods, groups) + expect(result).toHaveLength(1) + expect(result[0].kind).toBe('forloopflow') + const innerChildren = result[0].branches[0].children + expect(innerChildren).toHaveLength(2) // group + z + expect(innerChildren[0].kind).toBe('group') + expect(innerChildren[0].id).toBe('g1') + }) + + it('throws when group spans parallel branches (branchall)', () => { + const mods = [ + makeModule('a'), + makeBranchAll('ba', [ + ['x', 'y'], + ['p', 'q'] + ]), + makeModule('c') + ] + const groups = [makeGroup('g1', 'x', 'q', ['x', 'q'])] + expect(() => buildStructureTree(mods, groups)).toThrow(/could not be resolved/) + }) +}) + +describe('flattenStructureIds', () => { + it('flattens a simple tree', () => { + const modules = [makeModule('a'), makeModule('b'), makeModule('c')] + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])] + const tree = buildStructureTree(modules, groups) + const ids = flattenStructureIds(tree) + expect(ids).toEqual(['a', 'b', 'c']) + }) + + it('flattens nested groups', () => { + const mods = [makeModule('a'), makeModule('b'), makeModule('c'), makeModule('d')] + const groups = [ + makeGroup('outer', 'a', 'd', ['a', 'b', 'c', 'd']), + makeGroup('inner', 'b', 'c', ['b', 'c']) + ] + const tree = buildStructureTree(mods, groups) + const ids = flattenStructureIds(tree) + expect(ids).toEqual(['a', 'b', 'c', 'd']) + }) +}) + +describe('deriveGroupsFromStructure', () => { + it('derives group definitions with correct start/end', () => { + const modules = [makeModule('a'), makeModule('b'), makeModule('c')] + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])] + const tree = buildStructureTree(modules, groups) + const derived = deriveGroupsFromStructure(tree) + expect(derived).toHaveLength(1) + expect(derived[0].start_id).toBe('a') + expect(derived[0].end_id).toBe('b') + }) + + it('derives nested groups', () => { + const mods = [makeModule('a'), makeModule('b'), makeModule('c'), makeModule('d')] + const groups = [ + makeGroup('outer', 'a', 'd', ['a', 'b', 'c', 'd']), + makeGroup('inner', 'b', 'c', ['b', 'c']) + ] + const tree = buildStructureTree(mods, groups) + const derived = deriveGroupsFromStructure(tree) + expect(derived).toHaveLength(2) + expect(derived[0].start_id).toBe('a') + expect(derived[0].end_id).toBe('d') + expect(derived[1].start_id).toBe('b') + expect(derived[1].end_id).toBe('c') + }) +}) + +describe('findInStructure', () => { + it('finds a leaf node', () => { + const modules = [makeModule('a'), makeModule('b')] + const tree = buildStructureTree(modules, []) + const found = findInStructure(tree, 'b') + expect(found).toBeDefined() + expect(found!.index).toBe(1) + }) + + it('finds a node inside a group', () => { + const modules = [makeModule('a'), makeModule('b'), makeModule('c')] + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])] + const tree = buildStructureTree(modules, groups) + const found = findInStructure(tree, 'b') + expect(found).toBeDefined() + expect(found!.index).toBe(1) + // parentChildren should be the group's branch children + expect(found!.parentChildren).toHaveLength(2) + }) + + it('finds a group node by group id', () => { + const modules = [makeModule('a'), makeModule('b')] + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])] + const tree = buildStructureTree(modules, groups) + const found = findInStructure(tree, 'g1') + expect(found).toBeDefined() + expect(found!.index).toBe(0) + }) +}) + +describe('collectLeafIds', () => { + it('collects all leaf module IDs including inside containers', () => { + const mods = [makeForloop('loop', ['x', 'y']), makeModule('c')] + const tree = buildStructureTree(mods, []) + const ids = collectLeafIds(tree) + expect(ids).toEqual(['loop', 'x', 'y', 'c']) + }) +}) diff --git a/frontend/src/lib/components/graph/flowStructure.ts b/frontend/src/lib/components/graph/flowStructure.ts new file mode 100644 index 0000000000..e2a725bc85 --- /dev/null +++ b/frontend/src/lib/components/graph/flowStructure.ts @@ -0,0 +1,498 @@ +import type { FlowModule } from '$lib/gen' + +import type { FlowGroup, GraphGroup } from './groupEditor.svelte' +import { getContainerInnerArrays } from './groupEditor.svelte' +import { VIRTUAL_NODE_IDS } from './groupDetectionUtils' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ContainerKind = 'forloopflow' | 'whileloopflow' | 'branchone' | 'branchall' + +export type StructureBranch = { + label?: string + children: FlowStructureNode[] +} + +export type FlowStructureNode = { + /** FlowModule.id for modules, groupKey(g) for groups */ + id: string + kind: 'leaf' | 'group' | ContainerKind + /** Only present when kind === 'group' */ + group?: FlowGroup + /** Only present when kind === 'group' — flat module IDs for step count */ + moduleIds?: string[] + /** Child branches. leaf=[], group=[{children}], container=[{children}, ...] */ + branches: StructureBranch[] +} + +// --------------------------------------------------------------------------- +// Type guards +// --------------------------------------------------------------------------- +// Building the structure tree +// --------------------------------------------------------------------------- + +export function buildStructureTree( + modules: FlowModule[], + groups: GraphGroup[] +): FlowStructureNode[] { + const { items, consumed } = buildStructureTreeRecurse(modules, groups) + const unconsumed = groups.filter((g) => !consumed.has(g.id)) + if (unconsumed.length > 0) { + throw new Error( + `Group(s) ${unconsumed.map((g) => `'${g.id}'`).join(', ')} could not be resolved: ` + + `their start/end nodes do not belong to the same branch` + ) + } + return items +} + +export function moduleToStructureNode(mod: FlowModule): FlowStructureNode { + const innerArrays = getContainerInnerArrays(mod) + if (innerArrays.length === 0) { + return { id: mod.id, kind: 'leaf', branches: [] } + } + + const kind = (mod.value as any).type as ContainerKind + const branches: StructureBranch[] = innerArrays.map(({ get, label }) => ({ + label, + children: [] // filled later by recursion + })) + + return { id: mod.id, kind, branches } +} + +function buildStructureTreeRecurse( + modules: FlowModule[], + groups: GraphGroup[] +): { items: FlowStructureNode[]; consumed: Set } { + if (modules.length === 0) { + return { items: [], consumed: new Set() } + } + + const indexMap = new Map() + for (let i = 0; i < modules.length; i++) { + indexMap.set(modules[i].id, i) + } + + // Reject duplicate group IDs + const seenGroupIds = new Set() + for (const g of groups) { + if (seenGroupIds.has(g.id)) { + throw new Error(`Duplicate group id: '${g.id}'`) + } + seenGroupIds.add(g.id) + } + + // Reject groups referencing virtual nodes + for (const g of groups) { + if (VIRTUAL_NODE_IDS.has(g.start_id) || VIRTUAL_NODE_IDS.has(g.end_id)) { + throw new Error( + `Group '${g.id}' references virtual node: groups cannot include Input, Result, or Trigger` + ) + } + } + + // Partition: groups for this level vs rest + const levelGroups: GraphGroup[] = [] + const otherGroups: GraphGroup[] = [] + for (const g of groups) { + if (indexMap.has(g.start_id) && indexMap.has(g.end_id)) { + const s = indexMap.get(g.start_id)! + const e = indexMap.get(g.end_id)! + if (s > e) { + throw new Error( + `Group '${g.id}' has inverted range: start_id='${g.start_id}' (index ${s}) > end_id='${g.end_id}' (index ${e})` + ) + } + levelGroups.push(g) + } else { + otherGroups.push(g) + } + } + + // Validate no partial overlaps + for (let i = 0; i < levelGroups.length; i++) { + for (let j = i + 1; j < levelGroups.length; j++) { + const a = levelGroups[i] + const b = levelGroups[j] + const aStart = indexMap.get(a.start_id)! + const aEnd = indexMap.get(a.end_id)! + const bStart = indexMap.get(b.start_id)! + const bEnd = indexMap.get(b.end_id)! + + if (aEnd < bStart || bEnd < aStart) continue + if (aStart <= bStart && bEnd <= aEnd) continue + if (bStart <= aStart && aEnd <= bEnd) continue + + throw new Error(`Groups '${a.id}' and '${b.id}' overlap without nesting`) + } + } + + // Build grouped structure for this level + function build( + startIdx: number, + endIdx: number, + availableGroups: GraphGroup[] + ): FlowStructureNode[] { + const result: FlowStructureNode[] = [] + let i = startIdx + while (i <= endIdx) { + const candidates = availableGroups.filter((g) => { + const gStart = indexMap.get(g.start_id)! + const gEnd = indexMap.get(g.end_id)! + return gStart === i && gEnd <= endIdx + }) + candidates.sort((a, b) => { + const spanA = indexMap.get(a.end_id)! - indexMap.get(a.start_id)! + const spanB = indexMap.get(b.end_id)! - indexMap.get(b.start_id)! + return spanB - spanA + }) + + const group = candidates[0] + if (group) { + const gEnd = indexMap.get(group.end_id)! + const remaining = availableGroups.filter((g) => g.id !== group.id) + const innerNodes = build(i, gEnd, remaining) + + const moduleIds: string[] = [] + for (let k = i; k <= gEnd; k++) { + moduleIds.push(modules[k].id) + } + + result.push({ + id: group.id, + kind: 'group', + group: { + summary: group.summary, + note: group.note, + color: group.color, + autocollapse: group.autocollapse, + start_id: group.start_id, + end_id: group.end_id + }, + moduleIds, + branches: [{ children: innerNodes }] + }) + i = gEnd + 1 + } else { + result.push(moduleToStructureNode(modules[i])) + i++ + } + } + return result + } + + const result = build(0, modules.length - 1, levelGroups) + + // Recurse into containers with remaining unconsumed groups + const consumed = new Set(levelGroups.map((g) => g.id)) + let remaining = otherGroups + + function recurseIntoContainers(items: FlowStructureNode[]): void { + for (const item of items) { + if (item.kind === 'group') { + recurseIntoContainers(item.branches[0].children) + continue + } + if (item.branches.length === 0) continue + + // This is a container module — get inner FlowModule arrays and recurse + const modIdx = indexMap.get(item.id) + if (modIdx === undefined) continue + const mod = modules[modIdx] + + const innerArrays = getContainerInnerArrays(mod) + for (let bi = 0; bi < innerArrays.length; bi++) { + const inner = buildStructureTreeRecurse(innerArrays[bi].get(), remaining) + item.branches[bi] = { + label: item.branches[bi]?.label, + children: inner.items + } + for (const id of inner.consumed) consumed.add(id) + remaining = remaining.filter((g) => !inner.consumed.has(g.id)) + } + } + } + recurseIntoContainers(result) + + return { items: result, consumed } +} + +// --------------------------------------------------------------------------- +// Traversal utilities +// --------------------------------------------------------------------------- + +/** Generic DFS over the structure tree */ +export function dfsStructure( + nodes: FlowStructureNode[], + fn: (node: FlowStructureNode, parentArray: FlowStructureNode[]) => void +): void { + for (const node of nodes) { + fn(node, nodes) + for (const branch of node.branches) { + dfsStructure(branch.children, fn) + } + } +} + +/** Flatten to ordered module IDs (groups are transparent) */ +export function flattenStructureIds(nodes: FlowStructureNode[]): string[] { + const ids: string[] = [] + for (const node of nodes) { + if (node.kind === 'group') { + ids.push(...flattenStructureIds(node.branches[0].children)) + } else { + ids.push(node.id) + } + } + return ids +} + +/** Collect leaf module IDs recursively (including inside containers) */ +export function collectLeafIds(nodes: FlowStructureNode[]): string[] { + const ids: string[] = [] + for (const node of nodes) { + if (node.kind === 'group') { + ids.push(...collectLeafIds(node.branches[0].children)) + } else { + ids.push(node.id) + for (const branch of node.branches) { + ids.push(...collectLeafIds(branch.children)) + } + } + } + return ids +} + +// --------------------------------------------------------------------------- +// Finding nodes in the tree +// --------------------------------------------------------------------------- + +export type FindResult = { parentChildren: FlowStructureNode[]; index: number } + +export function findInStructure(nodes: FlowStructureNode[], id: string): FindResult | undefined { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i] + if (node.id === id) return { parentChildren: nodes, index: i } + for (const branch of node.branches) { + const found = findInStructure(branch.children, id) + if (found) return found + } + } + return undefined +} + +/** + * Match a structure node against a graph node ID. + * Handles group head/end IDs (group:X, group:X-end) and collapsed-group:X. + */ +export function matchStructureNode(node: FlowStructureNode, nodeId: string): boolean { + if (node.id === nodeId) return true + if (node.kind === 'group') { + return ( + nodeId === `group:${node.id}` || + nodeId === `group:${node.id}-end` || + nodeId === `collapsed-group:${node.id}` + ) + } + return false +} + +/** + * Find insert index using graph node IDs (handles group:X-end etc.). + * Returns the index OF the matched item (insert before it). + * For group-end nodes, returns index AFTER the group (insert after it). + */ +export function findInsertIndexByNodeId(items: FlowStructureNode[], targetNodeId: string): number { + // group-end: insert after the group + if (targetNodeId.startsWith('group:') && targetNodeId.endsWith('-end')) { + const groupId = targetNodeId.slice('group:'.length, -'-end'.length) + const idx = items.findIndex((n) => n.kind === 'group' && n.id === groupId) + return idx >= 0 ? idx + 1 : items.length + } + // Everything else: insert at the matched item's position + for (let i = 0; i < items.length; i++) { + if (matchStructureNode(items[i], targetNodeId)) return i + } + return items.length +} + +// --------------------------------------------------------------------------- +// Deriving groups from the structure tree +// --------------------------------------------------------------------------- + +export function deriveGroupsFromStructure(nodes: FlowStructureNode[]): FlowGroup[] { + const groups: FlowGroup[] = [] + for (const node of nodes) { + if (node.kind === 'group' && node.group) { + const flatIds = flattenStructureIds(node.branches[0].children) + if (flatIds.length === 0) { + console.warn(`deriveGroupsFromStructure: skipping empty group "${node.id}"`) + continue + } + groups.push({ + ...node.group, + start_id: flatIds[0], + end_id: flatIds[flatIds.length - 1] + }) + // Recurse for nested groups + groups.push(...deriveGroupsFromStructure(node.branches[0].children)) + } else { + for (const branch of node.branches) { + groups.push(...deriveGroupsFromStructure(branch.children)) + } + } + } + return groups +} + +// --------------------------------------------------------------------------- +// Syncing structure back to FlowModule[] +// --------------------------------------------------------------------------- + +/** + * Reconstruct a FlowModule[] from the structure tree, looking up originals + * from moduleMap and patching container inner arrays to match the tree ordering. + */ +export function applyStructureToModules( + nodes: FlowStructureNode[], + moduleMap: Map +): FlowModule[] { + const result: FlowModule[] = [] + for (const node of nodes) { + if (node.kind === 'group') { + // Groups are transparent — splice their children into this level + result.push(...applyStructureToModules(node.branches[0].children, moduleMap)) + } else { + const mod = moduleMap.get(node.id) + if (!mod) continue + + // Patch container inner arrays + if (node.branches.length > 0) { + const innerArrays = getContainerInnerArrays(mod) + for (let bi = 0; bi < innerArrays.length && bi < node.branches.length; bi++) { + innerArrays[bi].set(applyStructureToModules(node.branches[bi].children, moduleMap)) + } + } + + result.push(mod) + } + } + return result +} + +// --------------------------------------------------------------------------- +// Empty groups cleanup +// --------------------------------------------------------------------------- + +/** + * Walk the tree, remove group nodes that have no leaf modules, and return + * the removed groups. Mutates the input array in-place. + * Recurses depth-first so inner groups are cleaned before checking outer ones. + */ +export function removeEmptyGroups(nodes: FlowStructureNode[]): FlowGroup[] { + const removed: FlowGroup[] = [] + for (let i = nodes.length - 1; i >= 0; i--) { + const node = nodes[i] + if (node.kind === 'group' && node.group) { + // Recurse first — inner groups may become empty too + removed.push(...removeEmptyGroups(node.branches[0].children)) + if (flattenStructureIds(node.branches[0].children).length === 0) { + removed.push(node.group) + nodes.splice(i, 1) + } + } else { + for (const branch of node.branches) { + removed.push(...removeEmptyGroups(branch.children)) + } + } + } + return removed +} + +/** Walk the structure tree to compute nesting depth for each group (O(n)). */ +export function computeGroupDepths(tree: FlowStructureNode[]): Record { + const depths: Record = {} + function walk(nodes: FlowStructureNode[], groupDepth: number): void { + for (const node of nodes) { + if (node.kind === 'group') { + depths[node.id] = groupDepth + for (const branch of node.branches) { + walk(branch.children, groupDepth + 1) + } + } else { + for (const branch of node.branches) { + walk(branch.children, groupDepth) + } + } + } + } + walk(tree, 0) + return depths +} + +/** + * Find duplicate groups in the structure tree (same start_id:end_id after mutation). + * Returns the groups that should be removed (keeps the first, removes subsequent duplicates). + */ +export function findDuplicateGroups(nodes: FlowStructureNode[]): FlowGroup[] { + const duplicates: FlowGroup[] = [] + const seen = new Set() + + function walk(items: FlowStructureNode[]): void { + for (const node of items) { + if (node.kind === 'group' && node.group) { + const flatIds = flattenStructureIds(node.branches[0].children) + if (flatIds.length > 0) { + const key = `${flatIds[0]}:${flatIds[flatIds.length - 1]}` + if (seen.has(key)) { + duplicates.push(node.group) + } else { + seen.add(key) + } + } + walk(node.branches[0].children) + } else { + for (const branch of node.branches) { + walk(branch.children) + } + } + } + } + walk(nodes) + return duplicates +} + +/** Remove duplicate groups from the structure tree (keeps first occurrence). */ +export function removeDuplicateGroups(nodes: FlowStructureNode[]): FlowGroup[] { + const removed: FlowGroup[] = [] + const seen = new Set() + + function walk(items: FlowStructureNode[]): void { + for (let i = items.length - 1; i >= 0; i--) { + const node = items[i] + if (node.kind === 'group' && node.group) { + walk(node.branches[0].children) + const flatIds = flattenStructureIds(node.branches[0].children) + if (flatIds.length > 0) { + const key = `${flatIds[0]}:${flatIds[flatIds.length - 1]}` + if (seen.has(key)) { + // Replace group node with its children (ungroup) + removed.push(node.group) + items.splice(i, 1, ...node.branches[0].children) + } else { + seen.add(key) + } + } + } else { + for (const branch of node.branches) { + walk(branch.children) + } + } + } + } + walk(nodes) + return removed +} diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index 1d01b2e252..4fd7641096 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -8,6 +8,14 @@ import { getFlowModuleAssets, type AssetWithAltAccessType } from '../assets/lib' import { assetDisplaysAsOutputInFlowGraph } from './renderers/nodes/AssetNode.svelte' import type { ModulesTestStates, ModuleTestState } from '../modulesTest.svelte' import type { ModuleActionInfo } from '$lib/components/flows/flowDiff' +import { + type FlowStructureNode, + collectLeafIds, + findInsertIndexByNodeId, + buildStructureTree +} from './flowStructure' +import { groupKey, type FlowGroup } from './groupEditor.svelte' +import { computeGroupModuleIds } from './groupDetectionUtils' export type InsertKind = | 'script' @@ -62,6 +70,7 @@ export type GraphEventHandlers = { simplifyFlow: (b: boolean) => void expandSubflow: (id: string, path: string) => void minimizeSubflow: (id: string) => void + expandGroup: (groupId: string) => void updateMock: (detail: { mock: FlowModule['mock']; id: string }) => void testUpTo: (id: string) => void editInput: (moduleId: string, key: string) => void @@ -111,6 +120,9 @@ export type FlowNode = | AssetsOverflowedN | AiToolN | NewAiToolN + | CollapsedGroupN + | GroupHeadN + | GroupEndN export type InputN = { type: 'input2' @@ -300,6 +312,7 @@ export type AiToolN = { data: { tool: string type?: string + nameError?: string eventHandlers: GraphEventHandlers moduleId: string insertable: boolean @@ -315,6 +328,48 @@ export type NewAiToolN = { } } +export type CollapsedGroupN = { + type: 'collapsedGroup' + data: { + groupId: string + summary: string | undefined + note: string | undefined + color: string | undefined + autocollapse: boolean | undefined + stepCount: number + modules: FlowModule[] + flowModuleStates: Record | undefined + flowJob: Job | undefined + isOwner: boolean + suspendStatus: Record + showNotes: boolean + editMode: boolean + eventHandlers: GraphEventHandlers + } +} + +export type GroupHeadN = { + type: 'groupHead' + data: { + groupId: string + summary: string | undefined + note: string | undefined + color: string | undefined + autocollapse: boolean | undefined + editMode: boolean + showNotes: boolean + eventHandlers: GraphEventHandlers + wrapperWidth?: number + } +} + +export type GroupEndN = { + type: 'groupEnd' + data: { + groupId: string + } +} + export function topologicalSort( nodes: { id: string; parentIds?: string[] }[] ): { id: string; parentIds?: string[] }[] { @@ -335,22 +390,8 @@ export function topologicalSort( return result.reverse() } -// input2: InputNode, -// module: ModuleNode, -// branchAllStart: BranchAllStart, -// branchAllEnd: BranchAllEndNode, -// forLoopEnd: ForLoopEndNode, -// forLoopStart: ForLoopStartNode, -// result: ResultNode, -// whileLoopStart: ForLoopStartNode, -// whileLoopEnd: ForLoopEndNode, -// branchOneStart: BranchOneStart, -// branchOneEnd: BranchOneEndNode, -// subflowBound: SubflowBound, -// noBranch: NoBranchNode, -// trigger: TriggersNode - export function graphBuilder( + structureTree: FlowStructureNode[], modules: FlowModule[] | undefined, extra: { disableAi: boolean @@ -382,11 +423,9 @@ export function graphBuilder( selectedId: string | undefined, simplifiableFlow: SimplifiableFlow | undefined, flowPathForTriggerNode: string | undefined, - expandedSubflows: Record - // triggerProps?: { - // path?: string - // flowIsSimplifiable?: boolean - // } + expandedSubflows: Record, + showNotes: boolean, + collapsedGroupIds: Set ): { nodes: { [key: string]: NodeLayout } edges: Edge[] @@ -402,7 +441,13 @@ export function graphBuilder( const nodes: NodeLayout[] = [] const edges: Edge[] = [] - function addNode(module: FlowModule) { + // Lookup map from module ID to the original reactive FlowModule objects. + const moduleMap = new Map() + for (const m of getAllModules(modules, failureModule)) { + moduleMap.set(m.id, m) + } + + function addNode(module: FlowModule, extraData?: Record) { const duplicated = nodes.find((n) => n.id === module.id) if (duplicated) { console.log('Duplicated node detected: ', module, duplicated) @@ -423,7 +468,8 @@ export function graphBuilder( isOwner: extra.isOwner, flowJob: extra.flowJob, assets: getFlowModuleAssets(module, extra.additionalAssetsMap), - moduleAction: extra.moduleActions?.[module.id] + moduleAction: extra.moduleActions?.[module.id], + ...extraData }, type: 'module', selectable: true @@ -482,14 +528,20 @@ export function graphBuilder( customId?: string type?: string subModules?: FlowModule[] + currentItems?: FlowStructureNode[] disableMoveIds?: string[] } ) { parents[targetId] = [...(parents[targetId] ?? []), sourceId] - const mods = options?.subModules ?? modules - - let index = mods?.findIndex((m) => m.id === targetId) ?? -1 + let index: number + if (options?.currentItems) { + index = findInsertIndexByNodeId(options.currentItems, targetId) + } else { + const mods = options?.subModules ?? modules + const found = mods?.findIndex((m) => m.id === targetId) ?? -1 + index = found >= 0 ? found : (mods?.length ?? 0) + } const visited = new Set() const recStack = new Set() @@ -513,8 +565,7 @@ export function graphBuilder( simplifiedTriggerView: simplifiableFlow?.simplifiedFlow, disableMoveIds: options?.disableMoveIds, enableTrigger: sourceId === 'Input', - // If the index is -1, it means that the target module is not in the modules array, so we set it to the length of the array - index: index >= 0 ? index : (mods?.length ?? 0), + index, ...extra, insertable: extra.insertable && !options?.disableInsert && prefix == undefined, shouldOffsetInsertBtnDueToAssetNode: nodeIdsWithOutputAssets.has(sourceId) @@ -590,7 +641,7 @@ export function graphBuilder( } function processModules( - modules: FlowModule[], + items: FlowStructureNode[], branch: { rootId: string; branch: number } | undefined, beforeNode: NodeLayout, nextNode: NodeLayout | undefined, @@ -599,31 +650,166 @@ export function graphBuilder( disableMoveIds: string[] = [], parentIndex?: string ) { + // For subflow prefix rewriting, clone modules into moduleMap with prefixed IDs + // (avoid mutating reactive originals which would trigger state_unsafe_mutation in $derived) if (prefix != undefined) { - modules.forEach((m) => { - if (!m['oid']) { - m['oid'] = m.id + items.forEach((item) => { + if (item.kind === 'group') return + const m = moduleMap.get(item.id) + if (m) { + const oid = m['oid'] ?? m.id + const newId = 'subflow:' + prefix + oid + const clone = { ...m, id: newId, oid } as FlowModule & { oid: string } + clone['oid'] = oid + moduleMap.set(newId, clone) + item.id = newId } - m.id = 'subflow:' + prefix + m['oid'] }) } let previousId: string | undefined = undefined - if (modules.length === 0) { + if (items.length === 0) { if (nextNode) { addEdge(beforeNode.id, nextNode.id, branch, prefix, { - subModules: modules, + currentItems: items, disableMoveIds }) } } else { - modules.forEach((module, index) => { + items.forEach((item, index) => { + // --- Group items --- + if (item.kind === 'group') { + const g = item.group! + const gId = item.id + + if (collapsedGroupIds.has(gId)) { + // Collapsed group: single node + const nodeId = `collapsed-group:${gId}` + const leafIds = collectLeafIds(item.branches[0].children) + nodes.push({ + id: nodeId, + data: { + groupId: gId, + summary: g.summary, + note: g.note, + color: g.color, + autocollapse: g.autocollapse, + stepCount: item.moduleIds?.length ?? 0, + modules: leafIds + .map((id) => moduleMap.get(id)) + .filter((m): m is FlowModule => !!m), + flowModuleStates: extra.flowModuleStates, + flowJob: extra.flowJob, + isOwner: extra.isOwner, + suspendStatus: extra.suspendStatus, + showNotes, + editMode: prefix == undefined && extra.editMode, + eventHandlers + }, + type: 'collapsedGroup', + selectable: false + }) + + // Wire: previous → collapsedGroup + if (index > 0 && previousId) { + addEdge(previousId, nodeId, branch, prefix, { + currentItems: items, + disableMoveIds + }) + } + + previousId = nodeId + } else { + // Expanded group: head → recurse → end + const headId = `group:${gId}` + const endId = `group:${gId}-end` + const localDisableMoveIds = [...disableMoveIds, headId] + + const headNode: NodeLayout = { + id: headId, + data: { + groupId: gId, + summary: g.summary, + note: g.note, + color: g.color, + autocollapse: g.autocollapse, + editMode: prefix == undefined && extra.editMode, + showNotes, + eventHandlers + }, + type: 'groupHead', + selectable: false + } + + const endNode: NodeLayout = { + id: endId, + data: { + groupId: gId + }, + type: 'groupEnd', + selectable: false + } + + nodes.push(headNode) + nodes.push(endNode) + + // Wire: previous → headNode + if (index > 0 && previousId) { + addEdge(previousId, headId, branch, prefix, { + currentItems: items, + disableMoveIds + }) + } + + // Recurse inner modules + processModules( + item.branches[0].children, + { rootId: headId, branch: 0 }, + headNode, + endNode, + simplifiedTriggerView, + prefix, + localDisableMoveIds, + parentIndex + ) + + previousId = endId + } + + // Shared first/last edge wiring for groups + if (index === 0) { + addEdge( + beforeNode.id, + collapsedGroupIds.has(gId) ? `collapsed-group:${gId}` : `group:${gId}`, + undefined, + prefix, + { + currentItems: items, + disableMoveIds, + disableInsert: simplifiedTriggerView + } + ) + } + + if (index === items.length - 1 && previousId && nextNode) { + addEdge(previousId, nextNode.id, branch, prefix, { + currentItems: items, + disableMoveIds + }) + } + + return + } + + // --- Regular FlowModule items --- + const module = moduleMap.get(item.id) + if (!module) return const localDisableMoveIds = [...disableMoveIds, module.id] - // Add the edge between the previous node and the current one + // Inter-module edge: connect previous → current (expanded subflows handle their own) if (index > 0 && previousId && expandedSubflows[module.id] == undefined) { addEdge(previousId, module.id, branch, prefix, { - subModules: modules, + currentItems: items, disableMoveIds }) } @@ -699,7 +885,7 @@ export function graphBuilder( ) processModules( - branch.modules, + item.branches[branchIndex]?.children ?? [], { rootId: module.id, branch: branchIndex }, startNode, endNode, @@ -721,7 +907,7 @@ export function graphBuilder( id: `${module.id}-start`, data: { id: module.id, - module: module, + module: moduleMap.get(module.id) ?? module, simplifiedTriggerView, eventHandlers: eventHandlers, editMode: extra.editMode, @@ -758,7 +944,7 @@ export function graphBuilder( const selectedIterIndex = extra.flowModuleStates?.[module.id]?.selectedForloopIndex processModules( - module.value.modules, + item.branches[0]?.children ?? [], { rootId: module.id, branch: 0 }, startNode, endNode, @@ -797,7 +983,7 @@ export function graphBuilder( const selectedIterIndex = extra.flowModuleStates?.[module.id]?.selectedForloopIndex processModules( - module.value.modules, + item.branches[0]?.children ?? [], { rootId: module.id, branch: 0 }, startNode, endNode, @@ -824,21 +1010,6 @@ export function graphBuilder( } nodes.push(endNode) - // // Add default branch - // const defaultBranch: NodeLayout = { - // id: `${module.id}-default`, - // data: { - // offset: 0, - // label: 'Default', - // id: module.id, - // branchIndex: -1, - // eventHandlers: eventHandlers, - // branchOne: true, - // ...extra - // }, - // type: 'noBranch' - // } - const defaultBranch: NodeLayout = { id: `${module.id}-branch-default`, data: { @@ -862,7 +1033,7 @@ export function graphBuilder( }) processModules( - module.value.default, + item.branches[0]?.children ?? [], { rootId: module.id, branch: 0 }, defaultBranch, endNode, @@ -898,7 +1069,7 @@ export function graphBuilder( }) processModules( - branch.modules, + item.branches[branchIndex + 1]?.children ?? [], { rootId: module.id, branch: branchIndex + 1 }, startNode, endNode, @@ -911,9 +1082,9 @@ export function graphBuilder( previousId = endNode.id } else { - let expanded = expandedSubflows[module.id] - if (expanded) { - expanded = $state.snapshot(expanded) + const expandedData = expandedSubflows[module.id] + if (expandedData) { + const expandedMods = $state.snapshot(expandedData.modules) as FlowModule[] const startId = `${module.id}` const idWithoutPrefix = module.id.startsWith('subflow:') ? module.id.substring(8) @@ -935,12 +1106,12 @@ export function graphBuilder( if (previousId) { addEdge(previousId, startNode.id, branch, prefix, { - subModules: modules, + currentItems: items, disableMoveIds }) } else { addEdge(beforeNode.id, startNode.id, undefined, prefix, { - subModules: modules, + currentItems: items, disableMoveIds }) } @@ -961,8 +1132,20 @@ export function graphBuilder( nodes.push(endNode) + // Register expanded subflow modules so prefix rewriting finds + // the inner modules (not the parent flow's modules with same IDs) + for (const em of getAllModules(expandedMods)) { + moduleMap.set(em.id, em) + } + + const expandedGroups = (expandedData.groups ?? []).map((g) => ({ + ...g, + id: groupKey(g), + moduleIds: computeGroupModuleIds(g.start_id, g.end_id, getAllModules(expandedMods)) + })) + processModules( - expanded, + buildStructureTree(expandedMods, expandedGroups), undefined, startNode, endNode, @@ -980,15 +1163,15 @@ export function graphBuilder( if (index === 0 && expandedSubflows[module.id] == undefined) { addEdge(beforeNode.id, module.id, undefined, prefix, { - subModules: modules, + currentItems: items, disableMoveIds, disableInsert: simplifiedTriggerView }) } - if (index === modules.length - 1 && previousId && nextNode) { + if (index === items.length - 1 && previousId && nextNode) { addEdge(previousId, nextNode.id, branch, prefix, { - subModules: modules, + currentItems: items, disableMoveIds }) } @@ -996,10 +1179,12 @@ export function graphBuilder( } } + const topLevelItems = structureTree + if (simplifiableFlow?.simplifiedFlow === true && triggerNode) { - processModules(modules, undefined, triggerNode, undefined, true, undefined) + processModules(topLevelItems, undefined, triggerNode, undefined, true, undefined) } else { - processModules(modules, undefined, inputNode, resultNode, false, undefined) + processModules(topLevelItems, undefined, inputNode, resultNode, false, undefined) } if (failureModule) { diff --git a/frontend/src/lib/components/graph/graphContext.ts b/frontend/src/lib/components/graph/graphContext.ts index 8540982366..a6245769cf 100644 --- a/frontend/src/lib/components/graph/graphContext.ts +++ b/frontend/src/lib/components/graph/graphContext.ts @@ -4,6 +4,7 @@ import type { NoteManager } from './noteManager.svelte' import type { MoveManager } from './moveManager.svelte' import type { Writable } from 'svelte/store' import type { FlowDiffManager } from '../flows/flowDiffManager.svelte' +import type { GroupDisplayState } from './groupEditor.svelte' export type GraphContext = { selectionManager: SelectionManager @@ -14,6 +15,9 @@ export type GraphContext = { clearFlowSelection?: () => void yOffset?: number diffManager: FlowDiffManager + /** Current flow nodes for group validation (set by FlowGraphV2) */ + getFlowNodes?: () => { id: string; parentIds?: string[] }[] + groupDisplayState?: GroupDisplayState } const graphContextKey = 'FlowGraphContext' diff --git a/frontend/src/lib/components/graph/groupDetectionUtils.ts b/frontend/src/lib/components/graph/groupDetectionUtils.ts index e2c81c4d98..8dc6e3d8c2 100644 --- a/frontend/src/lib/components/graph/groupDetectionUtils.ts +++ b/frontend/src/lib/components/graph/groupDetectionUtils.ts @@ -1,7 +1,127 @@ +import { topologicalSort } from './graphBuilder.svelte' + +/** Node IDs synthesized by graphBuilder that are not real FlowModules */ +export const VIRTUAL_NODE_IDS = new Set(['Input', 'Result', 'Trigger']) + type FlowNode = { id: string; parentIds?: string[] } /** - * Use a simple algorithm to complete a group and split it into connected components + * Compute the set of module IDs that belong to a group defined by start_id and end_id. + * Uses the flattened module list (from getAllModules) and slices between start and end. + * Used for collapsed group icons, step count, and moduleToCollapsedGroup mapping. + */ +export function computeGroupModuleIds( + startId: string, + endId: string, + allModules: { id: string }[] +): string[] { + if (startId === endId) { + return allModules.some((m) => m.id === startId) ? [startId] : [] + } + + const startIdx = allModules.findIndex((m) => m.id === startId) + const endIdx = allModules.findIndex((m) => m.id === endId) + + if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) { + if (startIdx > endIdx) { + console.warn( + `computeGroupModuleIds: inverted range for group ${startId}→${endId} (${startIdx} > ${endIdx})` + ) + } + return [] + } + + return allModules.slice(startIdx, endIdx + 1).map((m) => m.id) +} + +/** + * Check whether a set of selected node IDs can form a valid group. + * Normalizes marker IDs (branch/forloop) to parent module IDs, + * then uses topologicalSort to derive start and end boundaries. + */ +export function canFormValidGroup( + selectedIds: string[], + flowNodes: FlowNode[], + excludeIds?: Set +): { valid: true; startId: string; endId: string } | { valid: false } { + if (selectedIds.length === 0) return { valid: false } + + // Normalize marker IDs to parent module IDs. + // -start (forloop head) → parent ID. -end/-branch-* → skip if parent covered, else reject. + const rawSet = new Set(selectedIds) + const normalizedIds: string[] = [] + + for (const id of selectedIds) { + const parentId = id.replace(/-(end|start|branch-.*)$/, '') + if (parentId === id) { + normalizedIds.push(id) + continue + } + if (id.endsWith('-start')) { + normalizedIds.push(parentId) + continue + } + // -end or -branch-*: parent must be covered (directly or via -start) + if (!rawSet.has(parentId) && !rawSet.has(`${parentId}-start`)) { + return { valid: false } + } + } + + if (normalizedIds.length === 0) return { valid: false } + const normalizedSet = new Set(normalizedIds) + + // Topo sort full graph, filter to normalized selection. + // Include raw matches plus all markers (-start, -end, -branch-*) whose parent is selected. + const sorted = topologicalSort(flowNodes) + const selectedSorted = sorted.filter((n) => { + if (normalizedSet.has(n.id)) return true + const parentId = n.id.replace(/-(end|start|branch-.*)$/, '') + return parentId !== n.id && normalizedSet.has(parentId) + }) + + if (selectedSorted.length === 0) return { valid: false } + + // Reject virtual or excluded nodes + if (selectedSorted.some((n) => VIRTUAL_NODE_IDS.has(n.id) || excludeIds?.has(n.id))) { + return { valid: false } + } + + // Topo order is bottom-first: first = bottom (end), last = top (start). + // Use raw IDs for BFS traversal, normalize for the returned group boundaries. + const rawStartId = selectedSorted[selectedSorted.length - 1].id + const rawEndId = selectedSorted[0].id + const startId = rawStartId.replace(/-(end|start|branch-.*)$/, '') + const endId = rawEndId.replace(/-(end|start|branch-.*)$/, '') + + // Verify all selected nodes lie between start and end in the DAG. + // BFS backward from rawEndId to rawStartId to collect reachable nodes. + // Normalize collected IDs so container markers map to their parent module. + const between = new Set() + const queue = [rawEndId] + const visited = new Set() + const parentMap = new Map(flowNodes.map((n) => [n.id, n.parentIds ?? []])) + while (queue.length > 0) { + const cur = queue.shift()! + if (visited.has(cur)) continue + visited.add(cur) + const normalized = cur.replace(/-(end|start|branch-.*)$/, '') + between.add(cur) + between.add(normalized) + if (cur === rawStartId) continue + for (const p of parentMap.get(cur) ?? []) { + queue.push(p) + } + } + if (!normalizedIds.every((id) => between.has(id))) { + return { valid: false } + } + + return { valid: true, startId, endId } +} + +/** + * Legacy utility: complete a group and split it into connected components. + * Still used by NoteEditor for FlowNote group notes (contained_node_ids). */ export function completeAndSplitGroup(groupNodes: string[], flowNodes: FlowNode[]): string[][] { if (groupNodes.length <= 1) { diff --git a/frontend/src/lib/components/graph/groupEditor.svelte.ts b/frontend/src/lib/components/graph/groupEditor.svelte.ts new file mode 100644 index 0000000000..8405390b96 --- /dev/null +++ b/frontend/src/lib/components/graph/groupEditor.svelte.ts @@ -0,0 +1,325 @@ +import type { FlowModule } from '$lib/gen' +import type { StateStore } from '$lib/utils' +import type { ExtendedOpenFlow } from '../flows/types' + +import { canFormValidGroup } from './groupDetectionUtils' +import type { NoteColor } from './noteColors' +import { DEFAULT_GROUP_NOTE_COLOR, getNextAvailableColor } from './noteColors' +import { getContext, setContext } from 'svelte' + +/** + * Type for a flow group (matches the generated type from OpenAPI). + * Members are computed dynamically from all nodes on paths between start_id and end_id. + */ +export type FlowGroup = { + summary?: string + note?: string + autocollapse?: boolean + start_id: string + end_id: string + color?: string +} + +/** Derive a stable key from a group's boundaries. Used as ephemeral ID for graph nodes, runtime state, etc. */ +export function groupKey(g: { start_id: string; end_id: string }): string { + return `${g.start_id}:${g.end_id}` +} + +/** + * Display state for flow groups inside the graph. + * Handles runtime collapse state and note height tracking. + * Similar to NoteManager — instantiated inside FlowGraphV2. + */ +export class GroupDisplayState { + #getGroups: () => FlowGroup[] + #runtimeCollapsedIds = $state>(new Set()) + #runtimeInitialized = $state(false) + #noteHeights = $state>({}) + renderCount = $state(0) + + constructor(getGroups: () => FlowGroup[]) { + this.#getGroups = getGroups + } + + /** Initialize runtime state from autocollapse. Safe to call from event handlers. */ + private ensureRuntimeInitialized(): void { + if (this.#runtimeInitialized) return + const groups = this.#getGroups() + this.#runtimeCollapsedIds = new Set( + groups.filter((g) => g.autocollapse).map((g) => groupKey(g)) + ) + this.#runtimeInitialized = true + } + + /** Check if a group is currently collapsed (runtime). Safe to call from $derived. */ + isRuntimeCollapsed(groupId: string): boolean { + if (!this.#runtimeInitialized) { + return this.#getGroups().find((g) => groupKey(g) === groupId)?.autocollapse ?? false + } + return this.#runtimeCollapsedIds.has(groupId) + } + + /** Toggle runtime collapse (Minimize2 button) */ + toggleRuntimeCollapse(groupId: string): void { + this.ensureRuntimeInitialized() + const next = new Set(this.#runtimeCollapsedIds) + if (next.has(groupId)) next.delete(groupId) + else next.add(groupId) + this.#runtimeCollapsedIds = next + this.render() + } + + /** Expand a group at runtime (CollapsedGroupNode click) */ + expandGroup(groupId: string): void { + this.ensureRuntimeInitialized() + const next = new Set(this.#runtimeCollapsedIds) + next.delete(groupId) + this.#runtimeCollapsedIds = next + this.render() + } + + /** Set note height for a group (used for layout spacing) */ + setNoteHeight(groupId: string, height: number): void { + if (this.#noteHeights[groupId] !== height) { + this.#noteHeights[groupId] = height + this.render() + } + } + + /** Get all note heights */ + getNoteHeights(): Record { + return this.#noteHeights + } + + /** Bump render counter to trigger re-layout */ + render(): void { + this.renderCount++ + } + + /** Remap runtime state when a group's boundaries (and thus its key) change */ + remapGroupKey(oldKey: string, newKey: string): void { + if (this.#runtimeCollapsedIds.has(oldKey)) { + const next = new Set(this.#runtimeCollapsedIds) + next.delete(oldKey) + next.add(newKey) + this.#runtimeCollapsedIds = next + } + if (oldKey in this.#noteHeights) { + this.#noteHeights[newKey] = this.#noteHeights[oldKey] + delete this.#noteHeights[oldKey] + } + } + + /** Get currently collapsed groups for graph builder. Safe to call from $derived. */ + getCollapsedGroups(): FlowGroup[] { + if (!this.#runtimeInitialized) { + return this.#getGroups().filter((g) => g.autocollapse) + } + return this.#getGroups().filter((g) => this.#runtimeCollapsedIds.has(groupKey(g))) + } +} + +/** + * Utility class for editing flow groups via direct flowStore mutations. + * Follows the same pattern as NoteEditor. + */ +export class GroupEditor { + private flowStore: StateStore + + constructor(flowStore: StateStore) { + this.flowStore = flowStore + } + + getGroups(): FlowGroup[] { + return this.flowStore.val.value?.groups || [] + } + + private setGroups(groups: FlowGroup[]): void { + if (this.flowStore.val.value) { + this.flowStore.val.value.groups = groups + } + } + + /** IDs that cannot be part of a group (preprocessor, failure module) */ + getExcludeIds(): Set { + const excludeIds = new Set() + const pp = this.flowStore.val.value?.preprocessor_module?.id + if (pp) excludeIds.add(pp) + const fm = this.flowStore.val.value?.failure_module?.id + if (fm) excludeIds.add(fm) + return excludeIds + } + + /** Check whether the given selection can form a valid group */ + canCreateGroup( + selectedIds: string[], + flowNodes: { id: string; parentIds?: string[] }[] + ): boolean { + const result = canFormValidGroup(selectedIds, flowNodes, this.getExcludeIds()) + if (!result.valid) return false + // Reject if a group with the same boundaries already exists + return !this.getGroups().some((g) => g.start_id === result.startId && g.end_id === result.endId) + } + + /** + * Create a new group from selected node IDs. + * Uses canFormValidGroup to determine start_id and end_id. + * Returns the generated group ID. + */ + createGroup( + moduleIds: string[], + flowNodes: { id: string; parentIds?: string[] }[] + ): string | undefined { + // Filter subflow node IDs (same logic as NoteEditor.createGroupNote) + let filteredIds = [...moduleIds] + const subflowIds: string[] = [] + for (const id of moduleIds) { + if (id.startsWith('subflow:')) { + const match = id.match(/^subflow:([^:]+)/) + if (match) { + subflowIds.push(match[1]) + } + } + } + if (subflowIds.length > 0) { + filteredIds = filteredIds.filter((id) => !subflowIds.includes(id)) + filteredIds = [...filteredIds, ...subflowIds] + } + + const result = canFormValidGroup(filteredIds, flowNodes, this.getExcludeIds()) + if (!result.valid) return undefined + + const groups = this.getGroups() + + // Reject duplicate: a group with the same boundaries already exists + if (groups.some((g) => g.start_id === result.startId && g.end_id === result.endId)) { + return undefined + } + const usedColors = new Set() + for (const group of groups) { + if (group.color) { + usedColors.add(group.color as NoteColor) + } + } + const color = usedColors.size > 0 ? getNextAvailableColor(usedColors) : DEFAULT_GROUP_NOTE_COLOR + + const newGroup: FlowGroup = { + start_id: result.startId, + end_id: result.endId, + color + } + this.setGroups([...groups, newGroup]) + return groupKey(newGroup) + } + + deleteGroup(groupId: string): void { + const groups = this.getGroups() + this.setGroups(groups.filter((g) => groupKey(g) !== groupId)) + } + + updateColor(groupId: string, color: NoteColor): void { + const groups = this.getGroups() + this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, color } : g))) + } + + updateSummary(groupId: string, summary: string): void { + const groups = this.getGroups() + this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, summary } : g))) + } + + updateNote(groupId: string, note: string | undefined): void { + const groups = this.getGroups() + this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, note } : g))) + } + + /** Add a note to a group (sets note to empty string to trigger the placeholder UI) */ + addNote(groupId: string): void { + this.updateNote(groupId, '') + } + + /** Remove a note from a group */ + removeNote(groupId: string): void { + this.updateNote(groupId, undefined) + } + + updateAutocollapse(groupId: string, autocollapse: boolean): void { + const groups = this.getGroups() + this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, autocollapse } : g))) + } +} + +export type GroupEditorContext = { + groupEditor: GroupEditor + canCreateGroup: StateStore +} + +const CONTEXT_KEY = 'GroupEditorContext' + +export function setGroupEditorContext( + groupEditor: GroupEditor, + canCreateGroup: StateStore +): void { + setContext(CONTEXT_KEY, { groupEditor, canCreateGroup }) +} + +export function getGroupEditorContext(): GroupEditorContext | undefined { + return getContext(CONTEXT_KEY) +} + +/** Height of the group header bar */ +export const GROUP_HEADER_HEIGHT = 22 + +/** Extra margin between the header and the first node */ +export const GROUP_TOP_MARGIN = 30 + +export type GraphGroup = FlowGroup & { + id: string + moduleIds: string[] +} + +export type ContainerInnerArray = { + get: () => FlowModule[] + set: (v: any) => void + label?: string +} + +/** Get inner arrays from a container FlowModule with direct get/set accessors. */ +export function getContainerInnerArrays(mod: FlowModule): ContainerInnerArray[] { + const val = mod.value as any + if (val.type === 'forloopflow' || val.type === 'whileloopflow') { + return [ + { + get: () => val.modules, + set: (v) => { + val.modules = v + } + } + ] + } else if (val.type === 'branchone') { + return [ + { + get: () => val.default, + set: (v) => { + val.default = v + }, + label: 'Default' + }, + ...val.branches.map((b: any, i: number) => ({ + get: () => b.modules, + set: (v: any) => { + b.modules = v + }, + label: b.summary || `Branch ${i + 1}` + })) + ] + } else if (val.type === 'branchall') { + return val.branches.map((b: any, i: number) => ({ + get: () => b.modules, + set: (v: any) => { + b.modules = v + }, + label: b.summary || `Branch ${i + 1}` + })) + } + return [] +} diff --git a/frontend/src/lib/components/graph/groupedModulesProxy.svelte.ts b/frontend/src/lib/components/graph/groupedModulesProxy.svelte.ts new file mode 100644 index 0000000000..6965baf531 --- /dev/null +++ b/frontend/src/lib/components/graph/groupedModulesProxy.svelte.ts @@ -0,0 +1,181 @@ +import { untrack } from 'svelte' +import type { FlowModule } from '$lib/gen' +import { type FlowGroup, type GraphGroup, groupKey } from './groupEditor.svelte' +import type { StateStore } from '$lib/utils' +import { getAllModules } from '../flows/flowExplorer' +import { computeGroupModuleIds } from './groupDetectionUtils' +import { stateSnapshot } from '$lib/svelte5Utils.svelte' +import { + buildStructureTree, + deriveGroupsFromStructure, + applyStructureToModules, + removeEmptyGroups, + findDuplicateGroups, + removeDuplicateGroups, + flattenStructureIds, + type FlowStructureNode +} from './flowStructure' + +export type ExtendedOpenFlow = { + value: { + modules: FlowModule[] + groups?: FlowGroup[] + [key: string]: any + } + [key: string]: any +} + +/** + * Reactive read-only view of the flow structure tree. + * The tree is always derived from flowStore (single source of truth). + * Mutations go through prepareMutation: snapshot → mutate → clean empty groups → commit. + */ +export class GroupedModulesProxy { + #items = $state([]) + #error = $state(undefined) + #flowStore: StateStore + + constructor(flowStore: StateStore) { + this.#flowStore = flowStore + this.rebuild() + + // Rebuild tree whenever store changes (undo/load/mutation) + $effect(() => { + void flowStore.val.value.modules + void flowStore.val.value.groups + untrack(() => this.rebuild()) + }) + } + + /** Reactive access to the structure tree (read-only view) */ + get items(): FlowStructureNode[] { + return this.#items + } + + /** Reactive access to build errors */ + get error(): unknown { + return this.#error + } + + /** + * Prepare a structural mutation without writing to the store yet. + * Returns the list of groups that became empty (already removed from the snapshot) + * and a `commit` function that writes the result to the store. + * + * If no groups were emptied, the caller can commit immediately. + * If groups were emptied, the caller should show a confirmation modal + * and call commit() only on user confirmation. + */ + prepareMutation( + mutate: (tree: FlowStructureNode[]) => void, + opts?: { + extraModules?: FlowModule[] + displayState?: import('./groupEditor.svelte').GroupDisplayState + } + ): { + emptiedGroups: FlowGroup[] + duplicateGroups: FlowGroup[] + commit: (commitOpts?: { removeDuplicates?: boolean }) => void + } { + const snapshot = $state.snapshot(this.#items) as FlowStructureNode[] + mutate(snapshot) + + // Clean up empty groups and collect which ones were removed + const emptiedGroups = removeEmptyGroups(snapshot) + // Detect groups that became duplicates after the mutation + const duplicateGroups = findDuplicateGroups(snapshot) + + const commit = (commitOpts?: { removeDuplicates?: boolean }) => { + if (commitOpts?.removeDuplicates && duplicateGroups.length > 0) { + removeDuplicateGroups(snapshot) + } + + // Remap runtime state for groups whose boundaries shifted + if (opts?.displayState) { + this.#remapChangedGroupKeys(snapshot, opts.displayState) + } + + // Build moduleMap lazily at commit time so it reflects the latest store state + const moduleMap = new Map() + for (const m of getAllModules(this.#flowStore.val.value.modules)) { + moduleMap.set(m.id, m) + } + if (opts?.extraModules) { + for (const m of opts.extraModules) { + moduleMap.set(m.id, m) + } + } + this.#flowStore.val.value.modules = applyStructureToModules(snapshot, moduleMap) + this.#flowStore.val.value.groups = deriveGroupsFromStructure(snapshot) + } + + return { emptiedGroups, duplicateGroups, commit } + } + + /** + * Convenience: prepare + auto-commit. Only use for mutations that cannot + * empty groups (e.g. inserts). Throws if groups are unexpectedly emptied. + * For mutations that may empty groups, use prepareMutation() directly. + */ + applyTreeMutation( + mutate: (tree: FlowStructureNode[]) => void, + opts?: { + extraModules?: FlowModule[] + displayState?: import('./groupEditor.svelte').GroupDisplayState + } + ): void { + const { emptiedGroups, duplicateGroups, commit } = this.prepareMutation(mutate, opts) + if (emptiedGroups.length > 0) { + console.error('applyTreeMutation: unexpected empty groups', emptiedGroups) + } + if (duplicateGroups.length > 0) { + console.error('applyTreeMutation: unexpected duplicate groups', duplicateGroups) + } + commit() + } + + /** Remap runtime state for group nodes whose boundaries shifted after a mutation. */ + #remapChangedGroupKeys( + snapshot: FlowStructureNode[], + displayState: import('./groupEditor.svelte').GroupDisplayState + ): void { + const walk = (nodes: FlowStructureNode[]) => { + for (const node of nodes) { + if (node.kind === 'group') { + const oldKey = node.id + const flatIds = flattenStructureIds(node.branches[0].children) + const newKey = flatIds.length > 0 ? `${flatIds[0]}:${flatIds[flatIds.length - 1]}` : null + if (newKey && oldKey !== newKey) { + displayState.remapGroupKey(oldKey, newKey) + } + walk(node.branches[0].children) + } else { + for (const branch of node.branches) { + walk(branch.children) + } + } + } + } + walk(snapshot) + } + + /** Rebuild from flowStore */ + private rebuild(): void { + const modules = stateSnapshot(this.#flowStore.val.value.modules) as FlowModule[] + const allGroups = this.#flowStore.val.value.groups ?? [] + const allModules = getAllModules(modules) + const graphGroups: GraphGroup[] = allGroups.map((g) => ({ + ...g, + id: groupKey(g), + moduleIds: computeGroupModuleIds(g.start_id, g.end_id, allModules) + })) + try { + this.#items = buildStructureTree(modules, graphGroups) + this.#error = undefined + } catch (e) { + // Intentionally preserve last-known-good #items so the graph + // can still render while the error is surfaced to the user. + this.#error = e + } + } +} diff --git a/frontend/src/lib/components/graph/model.ts b/frontend/src/lib/components/graph/model.ts index d6fcf4a33a..3618406371 100644 --- a/frontend/src/lib/components/graph/model.ts +++ b/frontend/src/lib/components/graph/model.ts @@ -1,4 +1,4 @@ -import type { FlowStatusModule, Job } from '$lib/gen' +import type { FlowStatusModule, Job, WorkflowStatus } from '$lib/gen' import type { StateStore } from '$lib/utils' import type { FlowState } from '../flows/flowState' @@ -67,6 +67,7 @@ export type GraphModuleState = { skipped?: boolean agent_actions?: FlowStatusModule['agent_actions'] script_hash?: string + workflow_as_code_status?: WorkflowStatus } export type NestedNodes = GraphItem[] diff --git a/frontend/src/lib/components/graph/moveManager.svelte.ts b/frontend/src/lib/components/graph/moveManager.svelte.ts index 605c4b849a..1539cc0802 100644 --- a/frontend/src/lib/components/graph/moveManager.svelte.ts +++ b/frontend/src/lib/components/graph/moveManager.svelte.ts @@ -204,9 +204,6 @@ export class MoveManager { for (const [edgeId, zone] of this.#registeredDropZones) { if (zone.disableMoveIds.includes(draggedId)) continue - // Skip edges adjacent to the dragged node (no-op move) - if (zone.sourceId === draggedId || zone.targetId === draggedId) continue - const dx = Math.abs(flowPos.x - zone.centerX) const dy = Math.abs(flowPos.y - zone.centerY) diff --git a/frontend/src/lib/components/graph/nodeExtraSpace.ts b/frontend/src/lib/components/graph/nodeExtraSpace.ts new file mode 100644 index 0000000000..f51309a60b --- /dev/null +++ b/frontend/src/lib/components/graph/nodeExtraSpace.ts @@ -0,0 +1,153 @@ +import type { FlowNote } from '../../gen' +import type { AssetWithAltAccessType } from '../assets/lib' +import { + assetDisplaysAsInputInFlowGraph, + assetDisplaysAsOutputInFlowGraph, + NODE_WITH_READ_ASSET_Y_OFFSET, + NODE_WITH_WRITE_ASSET_Y_OFFSET +} from './renderers/nodes/AssetNode.svelte' +import { + AI_TOOL_BASE_OFFSET, + AI_TOOL_ROW_OFFSET, + BELOW_ADDITIONAL_OFFSET +} from './renderers/nodes/AIToolNode.svelte' +import { topologicalSort } from './graphBuilder.svelte' +import { GROUP_HEADER_HEIGHT } from './groupEditor.svelte' +import type { GroupDisplayState } from './groupEditor.svelte' +import type { GraphModuleState } from '.' + +type NodeDep = { + id: string + parentIds?: string[] + data?: { assets?: AssetWithAltAccessType[]; module?: any } +} + +type ExtraSpace = { top: number; bottom: number; left: number; right: number } + +const MAX_TOOLS_PER_ROW = 2 + +/** + * Pre-compute extra top/bottom space each node needs for decorations + * (assets, AI tools, group headers, group notes). + */ +export function computeNodeExtraSpace( + graphNodes: NodeDep[], + opts: { + showAssets: boolean + showNotes: boolean + notes: FlowNote[] | undefined + noteTextHeights: Record + groupDisplayState: GroupDisplayState + insertable: boolean + flowModuleStates: Record | undefined + } +): Map | undefined { + const extraSpace = new Map() + + // 1. Assets + if (opts.showAssets) { + for (const node of graphNodes) { + const assets = node.data?.assets ?? [] + if (!assets.length) continue + const hasRead = assets.some(assetDisplaysAsInputInFlowGraph) + const hasWrite = assets.some(assetDisplaysAsOutputInFlowGraph) + if (hasRead || hasWrite) { + const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 } + extraSpace.set(node.id, { + ...prev, + top: prev.top + (hasRead ? NODE_WITH_READ_ASSET_Y_OFFSET : 0), + bottom: prev.bottom + (hasWrite ? NODE_WITH_WRITE_ASSET_Y_OFFSET : 0) + }) + } + } + } + + // 2. AI tools + for (const node of graphNodes) { + const mod = node.data?.module + if (!mod || mod.value?.type !== 'aiagent') continue + + const agentActions = !opts.insertable && opts.flowModuleStates?.[node.id]?.agent_actions + + if (agentActions) { + // Execution mode: tools below + const totalRows = Math.ceil(agentActions.length / MAX_TOOLS_PER_ROW) + const space = AI_TOOL_BASE_OFFSET + AI_TOOL_ROW_OFFSET * totalRows + BELOW_ADDITIONAL_OFFSET + const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 } + extraSpace.set(node.id, { ...prev, bottom: prev.bottom + space }) + } else { + // Edit mode: tools above + const tools = mod.value.tools ?? [] + const totalRows = Math.ceil(tools.length / MAX_TOOLS_PER_ROW) + (opts.insertable ? 1 : 0) + const space = AI_TOOL_BASE_OFFSET + AI_TOOL_ROW_OFFSET * totalRows + const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 } + extraSpace.set(node.id, { ...prev, top: prev.top + space }) + } + } + + // Topological sort (reversed: top-of-graph first) — shared by group notes and group headers + const sortedNodes = topologicalSort(graphNodes).reverse() + + // 3. Group notes (text above topmost node in each group note) + if (opts.showNotes) { + const groupNotes = (opts.notes ?? []).filter((n) => n.type === 'group') + if (groupNotes.length > 0) { + for (const groupNote of groupNotes) { + if (!groupNote.contained_node_ids?.length) continue + const topmostNodeId = sortedNodes.find((node) => + groupNote.contained_node_ids?.includes(node.id) + )?.id + if (topmostNodeId) { + const textHeight = opts.noteTextHeights[groupNote.id] || 60 + const spacing = textHeight + 16 // padding + const prev = extraSpace.get(topmostNodeId) ?? { + top: 0, + bottom: 0, + left: 0, + right: 0 + } + extraSpace.set(topmostNodeId, { + ...prev, + top: Math.max(prev.top, spacing + prev.top) + }) + } + } + } + } + + // 4. Collapsed group nodes are taller than regular nodes (header + module icons) + for (const node of graphNodes) { + if (node.id.startsWith('collapsed-group:')) { + const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 } + extraSpace.set(node.id, { + ...prev, + bottom: prev.bottom + GROUP_HEADER_HEIGHT + }) + } + } + + // 5. Group nodes (expanded heads and collapsed) with notes need extra height + if (opts.showNotes) { + const noteHeights = opts.groupDisplayState.getNoteHeights() + for (const node of graphNodes) { + let groupId: string | undefined + if (node.id.startsWith('group:') && !node.id.endsWith('-end')) { + groupId = node.id.slice('group:'.length) + } else if (node.id.startsWith('collapsed-group:')) { + groupId = node.id.slice('collapsed-group:'.length) + } + if (groupId) { + const noteHeight = noteHeights[groupId] + if (noteHeight && noteHeight > 0) { + const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 } + extraSpace.set(node.id, { + ...prev, + bottom: prev.bottom + noteHeight + }) + } + } + } + } + + return extraSpace.size > 0 ? extraSpace : undefined +} diff --git a/frontend/src/lib/components/graph/noteColors.ts b/frontend/src/lib/components/graph/noteColors.ts index f9024adf7e..2a82ed1f40 100644 --- a/frontend/src/lib/components/graph/noteColors.ts +++ b/frontend/src/lib/components/graph/noteColors.ts @@ -14,6 +14,7 @@ export enum NoteColor { export interface NoteColorConfig { background: string + backgroundLight: string outline: string outlineHover: string text: string @@ -24,70 +25,80 @@ export interface NoteColorConfig { export const NOTE_COLORS: Record = { [NoteColor.YELLOW]: { background: 'bg-yellow-200 dark:bg-yellow-900', - outline: 'outline-yellow-300 dark:outline-yellow-600', + backgroundLight: 'bg-yellow-400/5 dark:bg-yellow-600/5', + outline: 'outline-yellow-200 dark:outline-yellow-900', outlineHover: 'outline-yellow-300/60 dark:outline-yellow-600/60', text: 'text-yellow-900 dark:text-yellow-100', hover: 'hover:bg-yellow-200 dark:hover:bg-yellow-800' }, [NoteColor.BLUE]: { background: 'bg-blue-100 dark:bg-blue-950', - outline: 'outline-blue-300 dark:outline-blue-600', + backgroundLight: 'bg-blue-400/5 dark:bg-blue-600/5', + outline: 'outline-blue-100 dark:outline-blue-950', outlineHover: 'outline-blue-300/60 dark:outline-blue-600/60', text: 'text-blue-900 dark:text-blue-100', hover: 'hover:bg-blue-200 dark:hover:bg-blue-800' }, [NoteColor.GREEN]: { background: 'bg-green-200 dark:bg-green-900', - outline: 'outline-green-300 dark:outline-green-600', + backgroundLight: 'bg-green-400/5 dark:bg-green-600/5', + outline: 'outline-green-200 dark:outline-green-900', outlineHover: 'outline-green-300/60 dark:outline-green-600/60', text: 'text-green-900 dark:text-green-100', hover: 'hover:bg-green-200 dark:hover:bg-green-800' }, [NoteColor.PURPLE]: { background: 'bg-purple-200 dark:bg-purple-900', - outline: 'outline-purple-300 dark:outline-purple-600', + backgroundLight: 'bg-purple-400/5 dark:bg-purple-600/5', + outline: 'outline-purple-200 dark:outline-purple-900', outlineHover: 'outline-purple-300/60 dark:outline-purple-600/60', text: 'text-purple-900 dark:text-purple-100', hover: 'hover:bg-purple-200 dark:hover:bg-purple-800' }, [NoteColor.PINK]: { background: 'bg-pink-200 dark:bg-pink-900', - outline: 'outline-pink-300 dark:outline-pink-600', + backgroundLight: 'bg-pink-400/5 dark:bg-pink-600/5', + outline: 'outline-pink-200 dark:outline-pink-900', outlineHover: 'outline-pink-300/60 dark:outline-pink-600/60', text: 'text-pink-900 dark:text-pink-100', hover: 'hover:bg-pink-200 dark:hover:bg-pink-800' }, [NoteColor.ORANGE]: { background: 'bg-orange-200 dark:bg-orange-900', - outline: 'outline-orange-300 dark:outline-orange-600', + backgroundLight: 'bg-orange-400/5 dark:bg-orange-600/5', + outline: 'outline-orange-200 dark:outline-orange-900', outlineHover: 'outline-orange-300/60 dark:outline-orange-600/60', text: 'text-orange-900 dark:text-orange-100', hover: 'hover:bg-orange-200 dark:hover:bg-orange-800' }, [NoteColor.RED]: { background: 'bg-red-200 dark:bg-red-900', - outline: 'outline-red-300 dark:outline-red-600', + backgroundLight: 'bg-red-400/5 dark:bg-red-600/5', + outline: 'outline-red-200 dark:outline-red-900', outlineHover: 'outline-red-300/60 dark:outline-red-600/60', text: 'text-red-900 dark:text-red-100', hover: 'hover:bg-red-200 dark:hover:bg-red-800' }, [NoteColor.CYAN]: { background: 'bg-cyan-200 dark:bg-cyan-900', - outline: 'outline-cyan-300 dark:outline-cyan-600', + backgroundLight: 'bg-cyan-400/5 dark:bg-cyan-600/5', + outline: 'outline-cyan-200 dark:outline-cyan-900', outlineHover: 'outline-cyan-300/60 dark:outline-cyan-600/60', text: 'text-cyan-900 dark:text-cyan-100', hover: 'hover:bg-cyan-200 dark:hover:bg-cyan-800' }, [NoteColor.LIME]: { background: 'bg-lime-200 dark:bg-lime-900', - outline: 'outline-lime-300 dark:outline-lime-600', + backgroundLight: 'bg-lime-400/5 dark:bg-lime-600/5', + outline: 'outline-lime-200 dark:outline-lime-900', outlineHover: 'outline-lime-300/60 dark:outline-lime-600/60', text: 'text-lime-900 dark:text-lime-100', hover: 'hover:bg-lime-200 dark:hover:bg-lime-800' }, [NoteColor.GRAY]: { background: 'bg-gray-200 dark:bg-gray-800', - outline: 'outline-gray-300 dark:outline-gray-600', + backgroundLight: 'bg-gray-400/5 dark:bg-gray-600/5', + outline: 'outline-gray-200 dark:outline-gray-800', outlineHover: 'outline-gray-300/60 dark:outline-gray-600/60', text: 'text-gray-900 dark:text-gray-100', hover: 'hover:bg-gray-200 dark:hover:bg-gray-700' diff --git a/frontend/src/lib/components/graph/noteUtils.svelte.ts b/frontend/src/lib/components/graph/noteUtils.svelte.ts index 3350e07ce8..bd7fc2ce05 100644 --- a/frontend/src/lib/components/graph/noteUtils.svelte.ts +++ b/frontend/src/lib/components/graph/noteUtils.svelte.ts @@ -19,20 +19,6 @@ export type NodeDep = { export type NoteComputeResult = { noteNodes: (Node & NodeLayout)[] - newNodePositions: Record -} - -export type AIToolSpacingInfo = { - toolNodes: (Node & NodeLayout)[] - toolEdges: any[] - newNodePositions: Record -} - -export interface GroupNoteBounds { - x: number - y: number - width: number - height: number } let computeNoteNodesCache: @@ -283,14 +269,9 @@ export function computeNoteNodes( const allNoteNodes: (Node & NodeLayout)[] = [] - // Build a map of Y positions that need extra spacing for group notes - const yPosMap: Record = {} // Y position -> spacing needed - - // Group notes that need spacing + // Find topmost node per group note for layout calculation const groupNotes = notes.filter((n) => n.type === 'group') - const topMostNodesMap: Record = {} - const sortedNodes = topologicalSort(nodes).reverse() for (const groupNote of groupNotes) { @@ -298,47 +279,12 @@ export function computeNoteNodes( const topmostNodeId = sortedNodes.find((node) => groupNote.contained_node_ids?.includes(node.id) )?.id - const topmostNode = nodes.find((node) => node.id === topmostNodeId) - if (topmostNode) { - const textHeight = noteTextHeights[groupNote.id] || 60 - const spacing = textHeight + 16 // padding - // Mark this Y position as needing spacing - yPosMap[topmostNode.position.y] = Math.max(yPosMap[topmostNode.position.y] || 0, spacing) - topMostNodesMap[groupNote.id] = topmostNode.id + if (topmostNodeId) { + topMostNodesMap[groupNote.id] = topmostNodeId } } } - // Calculate new positions for nodes (offset by group notes) - const sortedNewNodes = nodes - .map((n) => ({ position: { ...n.position }, id: n.id })) - .sort((a, b) => a.position.y - b.position.y) - - let currentYOffset = 0 - let prevYPos = NaN - - for (const node of sortedNewNodes) { - if (node.position.y !== prevYPos) { - // Add spacing for group notes at this Y level - if (yPosMap[node.position.y]) { - currentYOffset += yPosMap[node.position.y] - } - prevYPos = node.position.y - } - node.position.y += currentYOffset - } - - // Create note nodes AFTER calculating adjusted node positions - // For group notes, we need to use the adjusted node positions - const adjustedNodes = sortedNewNodes.map((n) => { - const origNode = nodes.find((orig) => orig.id === n.id) - return { - ...n, - data: origNode?.data, - type: origNode?.type - } - }) - // Calculate all z-indexes at once using hierarchy information const noteZIndexes = calculateAllNoteZIndexes(notes, nodes) @@ -346,11 +292,11 @@ export function computeNoteNodes( const isGroupNote = note.type === 'group' const zIndex = noteZIndexes[note.id] - // Calculate position and size using adjusted node positions for group notes + // Calculate position and size using node positions for group notes const { position, size } = isGroupNote ? calculateGroupNoteLayout( note, - adjustedNodes, + nodes, noteTextHeights[note.id] || 60, topMostNodesMap[note.id] ) @@ -375,13 +321,8 @@ export function computeNoteNodes( allNoteNodes.push(noteNode) } - const newNodePositions: Record = Object.fromEntries( - sortedNewNodes.map((n) => [n.id, n.position]) - ) - const result: NoteComputeResult = { - noteNodes: allNoteNodes, - newNodePositions + noteNodes: allNoteNodes } // Cache the result diff --git a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte index 8f1ca0017f..5fbf084374 100644 --- a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte +++ b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte @@ -12,11 +12,14 @@ import type { GraphModuleState } from '../../model' import InsertModuleButton from '$lib/components/flows/map/InsertModuleButton.svelte' import { getGraphContext } from '../../graphContext' + import { GROUP_TOP_PADDING } from '$lib/components/graph/compoundLayout' const { useDataflow, showAssets, moveManager } = getGraphContext() let { id, + source, + target, sourceX, sourceY, sourcePosition, @@ -45,6 +48,13 @@ } } = $props() + // Derive group boundary from source/target node IDs + let groupBoundary: 'top' | 'bottom' | undefined = $derived.by(() => { + if (source.startsWith('group:') && !source.endsWith('-end')) return 'top' + if (target.startsWith('group:') && target.endsWith('-end')) return 'bottom' + return undefined + }) + let [edgePath] = $derived( getBezierPath({ sourceX, @@ -75,9 +85,15 @@ ) let centerY = $derived( - sourceY + - 32 + - (data.shouldOffsetInsertBtnDueToAssetNode && $showAssets ? NODE_WITH_WRITE_ASSET_Y_OFFSET : 0) + groupBoundary === 'bottom' + ? targetY + : groupBoundary === 'top' + ? sourceY + GROUP_TOP_PADDING / 2 + : sourceY + + 32 + + (data.shouldOffsetInsertBtnDueToAssetNode && $showAssets + ? NODE_WITH_WRITE_ASSET_Y_OFFSET + : 0) ) let isDragging = $derived(!!moveManager?.dragging) @@ -87,13 +103,13 @@ data?.insertable && draggedId !== undefined && !data.disableMoveIds?.includes(draggedId) && - data.sourceId !== draggedId && - data.targetId !== draggedId + source !== draggedId && + target !== draggedId ) - let isNearestDrop = $derived(isValidDropTarget && moveManager?.nearestDropZone?.edgeId === id) - let isAdjacentToDragged = $derived( - isDragging && (data?.sourceId === draggedId || data?.targetId === draggedId) + let isNearestDrop = $derived( + isValidDropTarget && moveManager?.nearestDropZone?.edgeId === id ? true : false ) + let isAdjacentToDragged = $derived(isDragging && (source === draggedId || target === draggedId)) // Register this edge's drop zone position with the drag manager so proximity // detection uses the actual xyflow-computed position rather than re-deriving it. @@ -161,7 +177,7 @@ {@render dropTargetIndicator(isNearestDrop)}
- {:else if data?.insertable && !$useDataflow && !moveManager?.movingModuleId && !isDragging} + {:else if data?.insertable && !groupBoundary && !$useDataflow && !moveManager?.movingModuleId && !isDragging}
- {#if !(moveManager.movingIds ?? [moveManager.movingModuleId]).some((id) => data.disableMoveIds?.includes(id))} + {#if !(moveManager.movingIds ?? [moveManager.movingModuleId]).some( (id) => data.disableMoveIds?.includes(id) )} diff --git a/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte index aec1fb03f8..50e4f9c87a 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte @@ -19,8 +19,6 @@ export function computeAssetNodes(nodes: NodeDep[]): { newAssetNodes: (Node & NodeLayout)[] newAssetEdges: Edge[] - // Nodes need to be offset on the y axis to make space for the asset nodes - newNodePositions: Record } { if (computeAssetNodesCache && deepEqual(nodes, computeAssetNodesCache[0])) { return computeAssetNodesCache[1] @@ -30,8 +28,6 @@ const allAssetNodes: (Node & NodeLayout)[] = [] const allAssetEdges: Edge[] = [] - const yPosMap: Record = {} - for (const node of nodes) { const assets = node.data.assets ?? [] if (!assets.length) continue @@ -47,13 +43,6 @@ const overflowedInputAssets = inputAssets.slice(3) const overflowedOutputAssets = outputAssets.slice(3) - // This allows calculating which nodes to offset on the y axis to - // make space for the asset nodes - if (inputAssets.length || outputAssets.length) - yPosMap[node.position.y] = yPosMap[node.position.y] ?? {} - if (inputAssets.length) yPosMap[node.position.y].r = true - if (outputAssets.length) yPosMap[node.position.y].w = true - // All asset nodes displayed on top const inputAssetNodes: (Node & AssetN)[] = displayedInputAssets.map((asset, i) => { let inputAssetXGap = 12 @@ -187,26 +176,9 @@ }) } - // Shift all nodes to make space for the new asset nodes - const sortedNewNodes = nodes - .map((n) => ({ position: { ...n.position }, id: n.id })) - .sort((a, b) => a.position.y - b.position.y) - - let currentYOffset = 0 - let prevYPos = NaN - for (const node of sortedNewNodes) { - if (node.position.y !== prevYPos) { - if (yPosMap[prevYPos]?.w) currentYOffset += NODE_WITH_WRITE_ASSET_Y_OFFSET - if (yPosMap[node.position.y]?.r) currentYOffset += NODE_WITH_READ_ASSET_Y_OFFSET - prevYPos = node.position.y - } - node.position.y += currentYOffset - } - let ret: ReturnType = { newAssetNodes: allAssetNodes, - newAssetEdges: allAssetEdges, - newNodePositions: Object.fromEntries(sortedNewNodes.map((n) => [n.id, n.position])) + newAssetEdges: allAssetEdges } computeAssetNodesCache = [clone(nodes), ret] return ret diff --git a/frontend/src/lib/components/graph/renderers/nodes/CollapsedGroupNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/CollapsedGroupNode.svelte new file mode 100644 index 0000000000..d2f3a373b8 --- /dev/null +++ b/frontend/src/lib/components/graph/renderers/nodes/CollapsedGroupNode.svelte @@ -0,0 +1,106 @@ + + + +
+
+ + {#if data.modules && data.modules.length > 0} +
+ +
+ {/if} +
+ + {#if waitingForEvents && data.flowJob && data.flowJob.type === 'QueuedJob'} +
+
+ +
+ . + . + . +
+
+
+ {#if data.flowJob.flow_status?.modules?.[data.flowJob.flow_status?.step]?.type === 'WaitingForEvents'} + + {:else if data.suspendStatus && Object.keys(data.suspendStatus).length > 0} +
+ {#each Object.values(data.suspendStatus) as suspendCount (suspendCount.job.id)} + + {/each} +
+ {/if} +
+
+ {/if} +
+
diff --git a/frontend/src/lib/components/graph/renderers/nodes/ForLoopStartNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/ForLoopStartNode.svelte index b066c828e4..422c0816c5 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/ForLoopStartNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/ForLoopStartNode.svelte @@ -39,14 +39,15 @@ if (!selectedId) return 'none' // Check direct children if (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow') { - if (module.value.modules.some((m) => m.id === selectedId)) { + const children = module.value.modules + if (children.some((m) => m.id === selectedId)) { return 'child' } // Check grandchildren - return module.value.modules.some( + return children.some( (m) => (m.value.type === 'forloopflow' || m.value.type === 'whileloopflow') && - m.value.modules.some((gm) => gm.id === selectedId) + m.value.modules.some((gm: FlowModule) => gm.id === selectedId) ) ? 'grandchild' : 'none' diff --git a/frontend/src/lib/components/graph/renderers/nodes/GroupEndNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/GroupEndNode.svelte new file mode 100644 index 0000000000..5532d3d659 --- /dev/null +++ b/frontend/src/lib/components/graph/renderers/nodes/GroupEndNode.svelte @@ -0,0 +1,15 @@ + + + + +
+
diff --git a/frontend/src/lib/components/graph/renderers/nodes/GroupHeadNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/GroupHeadNode.svelte new file mode 100644 index 0000000000..5dda213453 --- /dev/null +++ b/frontend/src/lib/components/graph/renderers/nodes/GroupHeadNode.svelte @@ -0,0 +1,28 @@ + + + +
+ +
+
diff --git a/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte index e9d8e342b4..c15c23a13e 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte @@ -44,7 +44,7 @@ // Define context menu items let noteDisabled = $derived( !noteEditorContext?.noteEditor || - (noteEditorContext?.noteEditor?.isNodeOnlyMemberOfGroupNote(data.id) ?? false) + (noteEditorContext?.noteEditor?.isNodeOnlyMemberOfGroupNote(data.id) ?? false) ) let isPreprocessor = $derived(data.id === 'preprocessor') @@ -138,7 +138,10 @@ isOwner={data.isOwner} maximizeSubflow={data.module?.value?.type == 'flow' && 'path' in data.module.value ? () => { - const path = data.module?.value && 'path' in data.module.value ? data.module.value['path'] as string : undefined + const path = + data.module?.value && 'path' in data.module.value + ? (data.module.value['path'] as string) + : undefined if (path) { data.eventHandlers.expandSubflow(data.id, path) } @@ -146,8 +149,8 @@ : undefined} /> -
- {#if (data.module?.value?.type === 'branchall' || data.module?.value?.type === 'branchone') && data.insertable} + {#if (data.module?.value?.type === 'branchall' || data.module?.value?.type === 'branchone') && data.insertable} +
- {/if} -
+
+ {/if} {/snippet} diff --git a/frontend/src/lib/components/graph/renderers/triggers/TriggersWrapper.svelte b/frontend/src/lib/components/graph/renderers/triggers/TriggersWrapper.svelte index 2ffa6a8d49..ccc5643981 100644 --- a/frontend/src/lib/components/graph/renderers/triggers/TriggersWrapper.svelte +++ b/frontend/src/lib/components/graph/renderers/triggers/TriggersWrapper.svelte @@ -53,7 +53,7 @@
diff --git a/frontend/src/lib/components/icons/ActiveCampaignIcon.svelte b/frontend/src/lib/components/icons/ActiveCampaignIcon.svelte new file mode 100644 index 0000000000..836b5a242b --- /dev/null +++ b/frontend/src/lib/components/icons/ActiveCampaignIcon.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/frontend/src/lib/components/icons/AlgoliaIcon.svelte b/frontend/src/lib/components/icons/AlgoliaIcon.svelte new file mode 100644 index 0000000000..e003c778e4 --- /dev/null +++ b/frontend/src/lib/components/icons/AlgoliaIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ApolloIcon.svelte b/frontend/src/lib/components/icons/ApolloIcon.svelte new file mode 100644 index 0000000000..85dbb1730a --- /dev/null +++ b/frontend/src/lib/components/icons/ApolloIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/BambooHrIcon.svelte b/frontend/src/lib/components/icons/BambooHrIcon.svelte new file mode 100644 index 0000000000..efbeec2ece --- /dev/null +++ b/frontend/src/lib/components/icons/BambooHrIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/BaremetricsIcon.svelte b/frontend/src/lib/components/icons/BaremetricsIcon.svelte new file mode 100644 index 0000000000..be550a88fc --- /dev/null +++ b/frontend/src/lib/components/icons/BaremetricsIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/BitlyIcon.svelte b/frontend/src/lib/components/icons/BitlyIcon.svelte new file mode 100644 index 0000000000..3cc3b43122 --- /dev/null +++ b/frontend/src/lib/components/icons/BitlyIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/BloggerIcon.svelte b/frontend/src/lib/components/icons/BloggerIcon.svelte new file mode 100644 index 0000000000..ab1d2d262f --- /dev/null +++ b/frontend/src/lib/components/icons/BloggerIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/BlueskyIcon.svelte b/frontend/src/lib/components/icons/BlueskyIcon.svelte new file mode 100644 index 0000000000..1f8545e325 --- /dev/null +++ b/frontend/src/lib/components/icons/BlueskyIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/BoxIcon.svelte b/frontend/src/lib/components/icons/BoxIcon.svelte new file mode 100644 index 0000000000..a6a5d5dc07 --- /dev/null +++ b/frontend/src/lib/components/icons/BoxIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/BrevoIcon.svelte b/frontend/src/lib/components/icons/BrevoIcon.svelte new file mode 100644 index 0000000000..2907c346f7 --- /dev/null +++ b/frontend/src/lib/components/icons/BrevoIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/BrexIcon.svelte b/frontend/src/lib/components/icons/BrexIcon.svelte new file mode 100644 index 0000000000..5f9fbf8de1 --- /dev/null +++ b/frontend/src/lib/components/icons/BrexIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/BrowserlessIcon.svelte b/frontend/src/lib/components/icons/BrowserlessIcon.svelte new file mode 100644 index 0000000000..768d51145c --- /dev/null +++ b/frontend/src/lib/components/icons/BrowserlessIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/BubbleIcon.svelte b/frontend/src/lib/components/icons/BubbleIcon.svelte new file mode 100644 index 0000000000..fdfb049e70 --- /dev/null +++ b/frontend/src/lib/components/icons/BubbleIcon.svelte @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/frontend/src/lib/components/icons/BuildkiteIcon.svelte b/frontend/src/lib/components/icons/BuildkiteIcon.svelte new file mode 100644 index 0000000000..282d782e31 --- /dev/null +++ b/frontend/src/lib/components/icons/BuildkiteIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/CalcomIcon.svelte b/frontend/src/lib/components/icons/CalcomIcon.svelte index e11dc76d24..382b8e7a56 100644 --- a/frontend/src/lib/components/icons/CalcomIcon.svelte +++ b/frontend/src/lib/components/icons/CalcomIcon.svelte @@ -18,6 +18,6 @@ > diff --git a/frontend/src/lib/components/icons/CalendlyIcon.svelte b/frontend/src/lib/components/icons/CalendlyIcon.svelte new file mode 100644 index 0000000000..7868621afd --- /dev/null +++ b/frontend/src/lib/components/icons/CalendlyIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/CircleCiIcon.svelte b/frontend/src/lib/components/icons/CircleCiIcon.svelte new file mode 100644 index 0000000000..92402ac6cd --- /dev/null +++ b/frontend/src/lib/components/icons/CircleCiIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/CiscoIcon.svelte b/frontend/src/lib/components/icons/CiscoIcon.svelte new file mode 100644 index 0000000000..d2d4b14bd8 --- /dev/null +++ b/frontend/src/lib/components/icons/CiscoIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ClearbitIcon.svelte b/frontend/src/lib/components/icons/ClearbitIcon.svelte new file mode 100644 index 0000000000..fc150c1dfe --- /dev/null +++ b/frontend/src/lib/components/icons/ClearbitIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ClerkIcon.svelte b/frontend/src/lib/components/icons/ClerkIcon.svelte new file mode 100644 index 0000000000..21baeae38d --- /dev/null +++ b/frontend/src/lib/components/icons/ClerkIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/CloseIcon.svelte b/frontend/src/lib/components/icons/CloseIcon.svelte new file mode 100644 index 0000000000..73e6f414f9 --- /dev/null +++ b/frontend/src/lib/components/icons/CloseIcon.svelte @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/CloudinaryIcon.svelte b/frontend/src/lib/components/icons/CloudinaryIcon.svelte new file mode 100644 index 0000000000..a61f3bbe7c --- /dev/null +++ b/frontend/src/lib/components/icons/CloudinaryIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/CockroachDbIcon.svelte b/frontend/src/lib/components/icons/CockroachDbIcon.svelte new file mode 100644 index 0000000000..d88f7c52de --- /dev/null +++ b/frontend/src/lib/components/icons/CockroachDbIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/CodaIcon.svelte b/frontend/src/lib/components/icons/CodaIcon.svelte new file mode 100644 index 0000000000..d8486224d9 --- /dev/null +++ b/frontend/src/lib/components/icons/CodaIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/CohereIcon.svelte b/frontend/src/lib/components/icons/CohereIcon.svelte new file mode 100644 index 0000000000..c0a4aea8ee --- /dev/null +++ b/frontend/src/lib/components/icons/CohereIcon.svelte @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/CoinMarketCapIcon.svelte b/frontend/src/lib/components/icons/CoinMarketCapIcon.svelte new file mode 100644 index 0000000000..770b60f40a --- /dev/null +++ b/frontend/src/lib/components/icons/CoinMarketCapIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/CoinbaseIcon.svelte b/frontend/src/lib/components/icons/CoinbaseIcon.svelte new file mode 100644 index 0000000000..b58a45fff0 --- /dev/null +++ b/frontend/src/lib/components/icons/CoinbaseIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ConfluenceIcon.svelte b/frontend/src/lib/components/icons/ConfluenceIcon.svelte new file mode 100644 index 0000000000..60dbcef880 --- /dev/null +++ b/frontend/src/lib/components/icons/ConfluenceIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ContentfulIcon.svelte b/frontend/src/lib/components/icons/ContentfulIcon.svelte new file mode 100644 index 0000000000..2af55d48fc --- /dev/null +++ b/frontend/src/lib/components/icons/ContentfulIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ConvertKitIcon.svelte b/frontend/src/lib/components/icons/ConvertKitIcon.svelte new file mode 100644 index 0000000000..2fbcf0e53f --- /dev/null +++ b/frontend/src/lib/components/icons/ConvertKitIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/DatoCmsIcon.svelte b/frontend/src/lib/components/icons/DatoCmsIcon.svelte new file mode 100644 index 0000000000..d462832e17 --- /dev/null +++ b/frontend/src/lib/components/icons/DatoCmsIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/DeelIcon.svelte b/frontend/src/lib/components/icons/DeelIcon.svelte new file mode 100644 index 0000000000..0b93a4a98f --- /dev/null +++ b/frontend/src/lib/components/icons/DeelIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/DeepLIcon.svelte b/frontend/src/lib/components/icons/DeepLIcon.svelte new file mode 100644 index 0000000000..028b061dc0 --- /dev/null +++ b/frontend/src/lib/components/icons/DeepLIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/DigitalOceanIcon.svelte b/frontend/src/lib/components/icons/DigitalOceanIcon.svelte new file mode 100644 index 0000000000..206e395175 --- /dev/null +++ b/frontend/src/lib/components/icons/DigitalOceanIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/DiscourseIcon.svelte b/frontend/src/lib/components/icons/DiscourseIcon.svelte new file mode 100644 index 0000000000..fd1e53f9a3 --- /dev/null +++ b/frontend/src/lib/components/icons/DiscourseIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/DocusignIcon.svelte b/frontend/src/lib/components/icons/DocusignIcon.svelte new file mode 100644 index 0000000000..91d96bac36 --- /dev/null +++ b/frontend/src/lib/components/icons/DocusignIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/DropboxIcon.svelte b/frontend/src/lib/components/icons/DropboxIcon.svelte new file mode 100644 index 0000000000..3facd1dda2 --- /dev/null +++ b/frontend/src/lib/components/icons/DropboxIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/EdgeDbIcon.svelte b/frontend/src/lib/components/icons/EdgeDbIcon.svelte index 2b94ecb6d0..cc31f328d4 100644 --- a/frontend/src/lib/components/icons/EdgeDbIcon.svelte +++ b/frontend/src/lib/components/icons/EdgeDbIcon.svelte @@ -1,30 +1,26 @@ - - - + + + diff --git a/frontend/src/lib/components/icons/EventbriteIcon.svelte b/frontend/src/lib/components/icons/EventbriteIcon.svelte new file mode 100644 index 0000000000..f851a933f3 --- /dev/null +++ b/frontend/src/lib/components/icons/EventbriteIcon.svelte @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/FigmaIcon.svelte b/frontend/src/lib/components/icons/FigmaIcon.svelte new file mode 100644 index 0000000000..e31fe0a71d --- /dev/null +++ b/frontend/src/lib/components/icons/FigmaIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/FlyIcon.svelte b/frontend/src/lib/components/icons/FlyIcon.svelte new file mode 100644 index 0000000000..02118212b2 --- /dev/null +++ b/frontend/src/lib/components/icons/FlyIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/FreshdeskIcon.svelte b/frontend/src/lib/components/icons/FreshdeskIcon.svelte new file mode 100644 index 0000000000..72f2d7b1fa --- /dev/null +++ b/frontend/src/lib/components/icons/FreshdeskIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/FrontAppIcon.svelte b/frontend/src/lib/components/icons/FrontAppIcon.svelte new file mode 100644 index 0000000000..dfba63c8b7 --- /dev/null +++ b/frontend/src/lib/components/icons/FrontAppIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/GhostCmsIcon.svelte b/frontend/src/lib/components/icons/GhostCmsIcon.svelte new file mode 100644 index 0000000000..018960d9b7 --- /dev/null +++ b/frontend/src/lib/components/icons/GhostCmsIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/GiphyIcon.svelte b/frontend/src/lib/components/icons/GiphyIcon.svelte new file mode 100644 index 0000000000..521a358840 --- /dev/null +++ b/frontend/src/lib/components/icons/GiphyIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/GitBookIcon.svelte b/frontend/src/lib/components/icons/GitBookIcon.svelte new file mode 100644 index 0000000000..b310c868f7 --- /dev/null +++ b/frontend/src/lib/components/icons/GitBookIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/GroqIcon.svelte b/frontend/src/lib/components/icons/GroqIcon.svelte new file mode 100644 index 0000000000..e8a32f8328 --- /dev/null +++ b/frontend/src/lib/components/icons/GroqIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/HoneybadgerIcon.svelte b/frontend/src/lib/components/icons/HoneybadgerIcon.svelte new file mode 100644 index 0000000000..cad2bb50d1 --- /dev/null +++ b/frontend/src/lib/components/icons/HoneybadgerIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/HttpIcon.svelte b/frontend/src/lib/components/icons/HttpIcon.svelte index eb0833ed0e..f6a29899d1 100644 --- a/frontend/src/lib/components/icons/HttpIcon.svelte +++ b/frontend/src/lib/components/icons/HttpIcon.svelte @@ -1,10 +1,10 @@ diff --git a/frontend/src/lib/components/icons/IftttIcon.svelte b/frontend/src/lib/components/icons/IftttIcon.svelte new file mode 100644 index 0000000000..166a28a3c3 --- /dev/null +++ b/frontend/src/lib/components/icons/IftttIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/IntercomIcon.svelte b/frontend/src/lib/components/icons/IntercomIcon.svelte new file mode 100644 index 0000000000..718a8179c0 --- /dev/null +++ b/frontend/src/lib/components/icons/IntercomIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/JoomlaIcon.svelte b/frontend/src/lib/components/icons/JoomlaIcon.svelte new file mode 100644 index 0000000000..34916abd9b --- /dev/null +++ b/frontend/src/lib/components/icons/JoomlaIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/LineIcon.svelte b/frontend/src/lib/components/icons/LineIcon.svelte new file mode 100644 index 0000000000..a33626338f --- /dev/null +++ b/frontend/src/lib/components/icons/LineIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/LinearIcon.svelte b/frontend/src/lib/components/icons/LinearIcon.svelte new file mode 100644 index 0000000000..e30e78639b --- /dev/null +++ b/frontend/src/lib/components/icons/LinearIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/LinodeIcon.svelte b/frontend/src/lib/components/icons/LinodeIcon.svelte new file mode 100644 index 0000000000..1a3c80e92e --- /dev/null +++ b/frontend/src/lib/components/icons/LinodeIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/LumaAiIcon.svelte b/frontend/src/lib/components/icons/LumaAiIcon.svelte new file mode 100644 index 0000000000..e98a2d3a4f --- /dev/null +++ b/frontend/src/lib/components/icons/LumaAiIcon.svelte @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/MSTeamsIcon.svelte b/frontend/src/lib/components/icons/MSTeamsIcon.svelte index d761a645a0..cbe8589023 100644 --- a/frontend/src/lib/components/icons/MSTeamsIcon.svelte +++ b/frontend/src/lib/components/icons/MSTeamsIcon.svelte @@ -1,78 +1,62 @@ - - - - - + diff --git a/frontend/src/lib/components/icons/MagentoIcon.svelte b/frontend/src/lib/components/icons/MagentoIcon.svelte new file mode 100644 index 0000000000..4723fd7d35 --- /dev/null +++ b/frontend/src/lib/components/icons/MagentoIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/MailchimpIcon.svelte b/frontend/src/lib/components/icons/MailchimpIcon.svelte index 55930c704b..2e1f756585 100644 --- a/frontend/src/lib/components/icons/MailchimpIcon.svelte +++ b/frontend/src/lib/components/icons/MailchimpIcon.svelte @@ -1,23 +1,35 @@ - - - - - - - + + + + + + + - \ No newline at end of file diff --git a/frontend/src/lib/components/icons/MandrillIcon.svelte b/frontend/src/lib/components/icons/MandrillIcon.svelte new file mode 100644 index 0000000000..c0098e9594 --- /dev/null +++ b/frontend/src/lib/components/icons/MandrillIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/MauticIcon.svelte b/frontend/src/lib/components/icons/MauticIcon.svelte new file mode 100644 index 0000000000..f040bf76d6 --- /dev/null +++ b/frontend/src/lib/components/icons/MauticIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/MediumIcon.svelte b/frontend/src/lib/components/icons/MediumIcon.svelte new file mode 100644 index 0000000000..7e96527f85 --- /dev/null +++ b/frontend/src/lib/components/icons/MediumIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/MiroIcon.svelte b/frontend/src/lib/components/icons/MiroIcon.svelte new file mode 100644 index 0000000000..64634c09b4 --- /dev/null +++ b/frontend/src/lib/components/icons/MiroIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/MistralIcon.svelte b/frontend/src/lib/components/icons/MistralIcon.svelte new file mode 100644 index 0000000000..efe6881da7 --- /dev/null +++ b/frontend/src/lib/components/icons/MistralIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/MixpanelIcon.svelte b/frontend/src/lib/components/icons/MixpanelIcon.svelte new file mode 100644 index 0000000000..aab286a515 --- /dev/null +++ b/frontend/src/lib/components/icons/MixpanelIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/MondayIcon.svelte b/frontend/src/lib/components/icons/MondayIcon.svelte new file mode 100644 index 0000000000..b9069f934a --- /dev/null +++ b/frontend/src/lib/components/icons/MondayIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/NeonDbIcon.svelte b/frontend/src/lib/components/icons/NeonDbIcon.svelte new file mode 100644 index 0000000000..eb226a64c1 --- /dev/null +++ b/frontend/src/lib/components/icons/NeonDbIcon.svelte @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/NetlifyIcon.svelte b/frontend/src/lib/components/icons/NetlifyIcon.svelte new file mode 100644 index 0000000000..5f1349d0ba --- /dev/null +++ b/frontend/src/lib/components/icons/NetlifyIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/OneSignalIcon.svelte b/frontend/src/lib/components/icons/OneSignalIcon.svelte new file mode 100644 index 0000000000..f52f181cf0 --- /dev/null +++ b/frontend/src/lib/components/icons/OneSignalIcon.svelte @@ -0,0 +1,14 @@ + + + + + + + diff --git a/frontend/src/lib/components/icons/OpenWeatherIcon.svelte b/frontend/src/lib/components/icons/OpenWeatherIcon.svelte new file mode 100644 index 0000000000..709c96e230 --- /dev/null +++ b/frontend/src/lib/components/icons/OpenWeatherIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/PagerDutyIcon.svelte b/frontend/src/lib/components/icons/PagerDutyIcon.svelte new file mode 100644 index 0000000000..5b0e9a45e3 --- /dev/null +++ b/frontend/src/lib/components/icons/PagerDutyIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/PandaDocIcon.svelte b/frontend/src/lib/components/icons/PandaDocIcon.svelte new file mode 100644 index 0000000000..b5191210d9 --- /dev/null +++ b/frontend/src/lib/components/icons/PandaDocIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/PaypalIcon.svelte b/frontend/src/lib/components/icons/PaypalIcon.svelte new file mode 100644 index 0000000000..d35b228e7f --- /dev/null +++ b/frontend/src/lib/components/icons/PaypalIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/PersonioIcon.svelte b/frontend/src/lib/components/icons/PersonioIcon.svelte new file mode 100644 index 0000000000..e33dbd0817 --- /dev/null +++ b/frontend/src/lib/components/icons/PersonioIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/PinterestIcon.svelte b/frontend/src/lib/components/icons/PinterestIcon.svelte new file mode 100644 index 0000000000..ac10cf2d7a --- /dev/null +++ b/frontend/src/lib/components/icons/PinterestIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/PipedriveIcon.svelte b/frontend/src/lib/components/icons/PipedriveIcon.svelte new file mode 100644 index 0000000000..70cce8aef5 --- /dev/null +++ b/frontend/src/lib/components/icons/PipedriveIcon.svelte @@ -0,0 +1,16 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/PlanetScaleIcon.svelte b/frontend/src/lib/components/icons/PlanetScaleIcon.svelte new file mode 100644 index 0000000000..e2948bffb0 --- /dev/null +++ b/frontend/src/lib/components/icons/PlanetScaleIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/PostmarkIcon.svelte b/frontend/src/lib/components/icons/PostmarkIcon.svelte new file mode 100644 index 0000000000..7e2cba89d5 --- /dev/null +++ b/frontend/src/lib/components/icons/PostmarkIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/PusherIcon.svelte b/frontend/src/lib/components/icons/PusherIcon.svelte new file mode 100644 index 0000000000..5f595760ea --- /dev/null +++ b/frontend/src/lib/components/icons/PusherIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/QuickbooksIcon.svelte b/frontend/src/lib/components/icons/QuickbooksIcon.svelte index aec9eebc79..8a24b47488 100644 --- a/frontend/src/lib/components/icons/QuickbooksIcon.svelte +++ b/frontend/src/lib/components/icons/QuickbooksIcon.svelte @@ -1,10 +1,22 @@ - \ No newline at end of file + diff --git a/frontend/src/lib/components/icons/RaindropIcon.svelte b/frontend/src/lib/components/icons/RaindropIcon.svelte new file mode 100644 index 0000000000..06d1fec6f4 --- /dev/null +++ b/frontend/src/lib/components/icons/RaindropIcon.svelte @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/ReadwiseIcon.svelte b/frontend/src/lib/components/icons/ReadwiseIcon.svelte new file mode 100644 index 0000000000..17e97e359b --- /dev/null +++ b/frontend/src/lib/components/icons/ReadwiseIcon.svelte @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/RenderIcon.svelte b/frontend/src/lib/components/icons/RenderIcon.svelte new file mode 100644 index 0000000000..e73b8b232f --- /dev/null +++ b/frontend/src/lib/components/icons/RenderIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ReplicateIcon.svelte b/frontend/src/lib/components/icons/ReplicateIcon.svelte new file mode 100644 index 0000000000..133f2b75ab --- /dev/null +++ b/frontend/src/lib/components/icons/ReplicateIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ResendIcon.svelte b/frontend/src/lib/components/icons/ResendIcon.svelte index 28e51bc152..6d224a5cd6 100644 --- a/frontend/src/lib/components/icons/ResendIcon.svelte +++ b/frontend/src/lib/components/icons/ResendIcon.svelte @@ -8,5 +8,5 @@ - + \ No newline at end of file diff --git a/frontend/src/lib/components/icons/RestIcon.svelte b/frontend/src/lib/components/icons/RestIcon.svelte index eb0833ed0e..f6a29899d1 100644 --- a/frontend/src/lib/components/icons/RestIcon.svelte +++ b/frontend/src/lib/components/icons/RestIcon.svelte @@ -1,10 +1,10 @@ diff --git a/frontend/src/lib/components/icons/RingCentralIcon.svelte b/frontend/src/lib/components/icons/RingCentralIcon.svelte new file mode 100644 index 0000000000..1850e965e8 --- /dev/null +++ b/frontend/src/lib/components/icons/RingCentralIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/RocketChatIcon.svelte b/frontend/src/lib/components/icons/RocketChatIcon.svelte new file mode 100644 index 0000000000..eaadd9f70f --- /dev/null +++ b/frontend/src/lib/components/icons/RocketChatIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/RunPodIcon.svelte b/frontend/src/lib/components/icons/RunPodIcon.svelte new file mode 100644 index 0000000000..c5e94c4f3f --- /dev/null +++ b/frontend/src/lib/components/icons/RunPodIcon.svelte @@ -0,0 +1,13 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/RustIcon.svelte b/frontend/src/lib/components/icons/RustIcon.svelte index 364dece5be..c2bcbf614c 100644 --- a/frontend/src/lib/components/icons/RustIcon.svelte +++ b/frontend/src/lib/components/icons/RustIcon.svelte @@ -1,10 +1,10 @@ - - + diff --git a/frontend/src/lib/components/icons/SalesforceIcon.svelte b/frontend/src/lib/components/icons/SalesforceIcon.svelte new file mode 100644 index 0000000000..48e37a8cd5 --- /dev/null +++ b/frontend/src/lib/components/icons/SalesforceIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/SegmentIcon.svelte b/frontend/src/lib/components/icons/SegmentIcon.svelte new file mode 100644 index 0000000000..c7f6231d73 --- /dev/null +++ b/frontend/src/lib/components/icons/SegmentIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/SentryIcon.svelte b/frontend/src/lib/components/icons/SentryIcon.svelte new file mode 100644 index 0000000000..637923b99b --- /dev/null +++ b/frontend/src/lib/components/icons/SentryIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ServiceNowIcon.svelte b/frontend/src/lib/components/icons/ServiceNowIcon.svelte new file mode 100644 index 0000000000..9b2994ed54 --- /dev/null +++ b/frontend/src/lib/components/icons/ServiceNowIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ShortcutIcon.svelte b/frontend/src/lib/components/icons/ShortcutIcon.svelte new file mode 100644 index 0000000000..f03d6ea6cc --- /dev/null +++ b/frontend/src/lib/components/icons/ShortcutIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/SigNozIcon.svelte b/frontend/src/lib/components/icons/SigNozIcon.svelte new file mode 100644 index 0000000000..7243776c88 --- /dev/null +++ b/frontend/src/lib/components/icons/SigNozIcon.svelte @@ -0,0 +1,13 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/SmartsheetIcon.svelte b/frontend/src/lib/components/icons/SmartsheetIcon.svelte new file mode 100644 index 0000000000..9085ff8cb0 --- /dev/null +++ b/frontend/src/lib/components/icons/SmartsheetIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/SpeechifyIcon.svelte b/frontend/src/lib/components/icons/SpeechifyIcon.svelte new file mode 100644 index 0000000000..e2fc29e099 --- /dev/null +++ b/frontend/src/lib/components/icons/SpeechifyIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/SplitwiseIcon.svelte b/frontend/src/lib/components/icons/SplitwiseIcon.svelte new file mode 100644 index 0000000000..3d88d66dd0 --- /dev/null +++ b/frontend/src/lib/components/icons/SplitwiseIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/StravaIcon.svelte b/frontend/src/lib/components/icons/StravaIcon.svelte new file mode 100644 index 0000000000..d0d1d52ca6 --- /dev/null +++ b/frontend/src/lib/components/icons/StravaIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/TallyIcon.svelte b/frontend/src/lib/components/icons/TallyIcon.svelte new file mode 100644 index 0000000000..f494f124bb --- /dev/null +++ b/frontend/src/lib/components/icons/TallyIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/TelnyxIcon.svelte b/frontend/src/lib/components/icons/TelnyxIcon.svelte new file mode 100644 index 0000000000..a326114085 --- /dev/null +++ b/frontend/src/lib/components/icons/TelnyxIcon.svelte @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/ThreadsIcon.svelte b/frontend/src/lib/components/icons/ThreadsIcon.svelte new file mode 100644 index 0000000000..67a09e86bf --- /dev/null +++ b/frontend/src/lib/components/icons/ThreadsIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/TodoistIcon.svelte b/frontend/src/lib/components/icons/TodoistIcon.svelte new file mode 100644 index 0000000000..fee8f85134 --- /dev/null +++ b/frontend/src/lib/components/icons/TodoistIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/TogetherAiIcon.svelte b/frontend/src/lib/components/icons/TogetherAiIcon.svelte new file mode 100644 index 0000000000..fd71832e7d --- /dev/null +++ b/frontend/src/lib/components/icons/TogetherAiIcon.svelte @@ -0,0 +1,29 @@ + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/TrelloIcon.svelte b/frontend/src/lib/components/icons/TrelloIcon.svelte index c93ecc1b70..b37ffa6eac 100644 --- a/frontend/src/lib/components/icons/TrelloIcon.svelte +++ b/frontend/src/lib/components/icons/TrelloIcon.svelte @@ -1,10 +1,31 @@ - \ No newline at end of file + diff --git a/frontend/src/lib/components/icons/TursoIcon.svelte b/frontend/src/lib/components/icons/TursoIcon.svelte new file mode 100644 index 0000000000..91514bcde0 --- /dev/null +++ b/frontend/src/lib/components/icons/TursoIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/TwitchIcon.svelte b/frontend/src/lib/components/icons/TwitchIcon.svelte new file mode 100644 index 0000000000..1b88756005 --- /dev/null +++ b/frontend/src/lib/components/icons/TwitchIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/TwitterIcon.svelte b/frontend/src/lib/components/icons/TwitterIcon.svelte new file mode 100644 index 0000000000..55e510c346 --- /dev/null +++ b/frontend/src/lib/components/icons/TwitterIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/TypeformIcon.svelte b/frontend/src/lib/components/icons/TypeformIcon.svelte index c9f0101c0b..6e2f07560d 100644 --- a/frontend/src/lib/components/icons/TypeformIcon.svelte +++ b/frontend/src/lib/components/icons/TypeformIcon.svelte @@ -18,6 +18,6 @@ > diff --git a/frontend/src/lib/components/icons/VercelIcon.svelte b/frontend/src/lib/components/icons/VercelIcon.svelte new file mode 100644 index 0000000000..89be3c8070 --- /dev/null +++ b/frontend/src/lib/components/icons/VercelIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/WebflowIcon.svelte b/frontend/src/lib/components/icons/WebflowIcon.svelte new file mode 100644 index 0000000000..a445732c22 --- /dev/null +++ b/frontend/src/lib/components/icons/WebflowIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/WooCommerceIcon.svelte b/frontend/src/lib/components/icons/WooCommerceIcon.svelte new file mode 100644 index 0000000000..cadf03e232 --- /dev/null +++ b/frontend/src/lib/components/icons/WooCommerceIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/WordpressIcon.svelte b/frontend/src/lib/components/icons/WordpressIcon.svelte new file mode 100644 index 0000000000..b84d5b9c17 --- /dev/null +++ b/frontend/src/lib/components/icons/WordpressIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/XataIcon.svelte b/frontend/src/lib/components/icons/XataIcon.svelte new file mode 100644 index 0000000000..033dd2e331 --- /dev/null +++ b/frontend/src/lib/components/icons/XataIcon.svelte @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/YelpIcon.svelte b/frontend/src/lib/components/icons/YelpIcon.svelte new file mode 100644 index 0000000000..636b466b07 --- /dev/null +++ b/frontend/src/lib/components/icons/YelpIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/YnabIcon.svelte b/frontend/src/lib/components/icons/YnabIcon.svelte new file mode 100644 index 0000000000..24372aab69 --- /dev/null +++ b/frontend/src/lib/components/icons/YnabIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/YoutubeIcon.svelte b/frontend/src/lib/components/icons/YoutubeIcon.svelte new file mode 100644 index 0000000000..674c3e8052 --- /dev/null +++ b/frontend/src/lib/components/icons/YoutubeIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ZendeskIcon.svelte b/frontend/src/lib/components/icons/ZendeskIcon.svelte index c52b7295f2..45423b520e 100644 --- a/frontend/src/lib/components/icons/ZendeskIcon.svelte +++ b/frontend/src/lib/components/icons/ZendeskIcon.svelte @@ -9,7 +9,7 @@ - + diff --git a/frontend/src/lib/components/icons/ZeroTierIcon.svelte b/frontend/src/lib/components/icons/ZeroTierIcon.svelte new file mode 100644 index 0000000000..57bc4ba1a1 --- /dev/null +++ b/frontend/src/lib/components/icons/ZeroTierIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/ZoomIcon.svelte b/frontend/src/lib/components/icons/ZoomIcon.svelte new file mode 100644 index 0000000000..e68d208181 --- /dev/null +++ b/frontend/src/lib/components/icons/ZoomIcon.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/icons/index.ts b/frontend/src/lib/components/icons/index.ts index 07f74d477f..78767697a1 100644 --- a/frontend/src/lib/components/icons/index.ts +++ b/frontend/src/lib/components/icons/index.ts @@ -104,6 +104,116 @@ import McpIcon from './McpIcon.svelte' import SageIcon from './SageIcon.svelte' import ZohoIcon from './ZohoIcon.svelte' import PocketIdIcon from './PocketIdIcon.svelte' +import DuckDbIcon from './DuckDbIcon.svelte' +import ActiveCampaignIcon from './ActiveCampaignIcon.svelte' +import AlgoliaIcon from './AlgoliaIcon.svelte' +import BambooHrIcon from './BambooHrIcon.svelte' +import BaremetricsIcon from './BaremetricsIcon.svelte' +import BitlyIcon from './BitlyIcon.svelte' +import BloggerIcon from './BloggerIcon.svelte' +import BlueskyIcon from './BlueskyIcon.svelte' +import BoxIcon from './BoxIcon.svelte' +import BrevoIcon from './BrevoIcon.svelte' +import BuildkiteIcon from './BuildkiteIcon.svelte' +import CalendlyIcon from './CalendlyIcon.svelte' +import CircleCiIcon from './CircleCiIcon.svelte' +import CiscoIcon from './CiscoIcon.svelte' +import ClerkIcon from './ClerkIcon.svelte' +import CloudinaryIcon from './CloudinaryIcon.svelte' +import CockroachDbIcon from './CockroachDbIcon.svelte' +import CodaIcon from './CodaIcon.svelte' +import CoinbaseIcon from './CoinbaseIcon.svelte' +import CoinMarketCapIcon from './CoinMarketCapIcon.svelte' +import ConfluenceIcon from './ConfluenceIcon.svelte' +import ContentfulIcon from './ContentfulIcon.svelte' +import DatoCmsIcon from './DatoCmsIcon.svelte' +import DeepLIcon from './DeepLIcon.svelte' +import DigitalOceanIcon from './DigitalOceanIcon.svelte' +import DiscourseIcon from './DiscourseIcon.svelte' +import DocusignIcon from './DocusignIcon.svelte' +import DropboxIcon from './DropboxIcon.svelte' +import EventbriteIcon from './EventbriteIcon.svelte' +import FigmaIcon from './FigmaIcon.svelte' +import FlyIcon from './FlyIcon.svelte' +import FreshdeskIcon from './FreshdeskIcon.svelte' +import FrontAppIcon from './FrontAppIcon.svelte' +import GhostCmsIcon from './GhostCmsIcon.svelte' +import GiphyIcon from './GiphyIcon.svelte' +import GitBookIcon from './GitBookIcon.svelte' +import HoneybadgerIcon from './HoneybadgerIcon.svelte' +import IftttIcon from './IftttIcon.svelte' +import IntercomIcon from './IntercomIcon.svelte' +import LineIcon from './LineIcon.svelte' +import LinearIcon from './LinearIcon.svelte' +import LinodeIcon from './LinodeIcon.svelte' +import MediumIcon from './MediumIcon.svelte' +import MiroIcon from './MiroIcon.svelte' +import MistralIcon from './MistralIcon.svelte' +import MixpanelIcon from './MixpanelIcon.svelte' +import MondayIcon from './MondayIcon.svelte' +import NeonDbIcon from './NeonDbIcon.svelte' +import NetlifyIcon from './NetlifyIcon.svelte' +import OneSignalIcon from './OneSignalIcon.svelte' +import PagerDutyIcon from './PagerDutyIcon.svelte' +import PandaDocIcon from './PandaDocIcon.svelte' +import PaypalIcon from './PaypalIcon.svelte' +import PersonioIcon from './PersonioIcon.svelte' +import PinterestIcon from './PinterestIcon.svelte' +import PipedriveIcon from './PipedriveIcon.svelte' +import PlanetScaleIcon from './PlanetScaleIcon.svelte' +import PostmarkIcon from './PostmarkIcon.svelte' +import PusherIcon from './PusherIcon.svelte' +import RenderIcon from './RenderIcon.svelte' +import ReplicateIcon from './ReplicateIcon.svelte' +import RingCentralIcon from './RingCentralIcon.svelte' +import SalesforceIcon from './SalesforceIcon.svelte' +import SegmentIcon from './SegmentIcon.svelte' +import SentryIcon from './SentryIcon.svelte' +import ServiceNowIcon from './ServiceNowIcon.svelte' +import ShortcutIcon from './ShortcutIcon.svelte' +import SmartsheetIcon from './SmartsheetIcon.svelte' +import StravaIcon from './StravaIcon.svelte' +import ThreadsIcon from './ThreadsIcon.svelte' +import TodoistIcon from './TodoistIcon.svelte' +import TursoIcon from './TursoIcon.svelte' +import TwitchIcon from './TwitchIcon.svelte' +import TwitterIcon from './TwitterIcon.svelte' +import VercelIcon from './VercelIcon.svelte' +import WebflowIcon from './WebflowIcon.svelte' +import WooCommerceIcon from './WooCommerceIcon.svelte' +import WordpressIcon from './WordpressIcon.svelte' +import XataIcon from './XataIcon.svelte' +import YelpIcon from './YelpIcon.svelte' +import YoutubeIcon from './YoutubeIcon.svelte' +import ZoomIcon from './ZoomIcon.svelte' +import CohereIcon from './CohereIcon.svelte' +import TallyIcon from './TallyIcon.svelte' +import ClearbitIcon from './ClearbitIcon.svelte' +import RaindropIcon from './RaindropIcon.svelte' +import MagentoIcon from './MagentoIcon.svelte' +import DeelIcon from './DeelIcon.svelte' +import GroqIcon from './GroqIcon.svelte' +import TogetherAiIcon from './TogetherAiIcon.svelte' +import RunPodIcon from './RunPodIcon.svelte' +import SigNozIcon from './SigNozIcon.svelte' +import ReadwiseIcon from './ReadwiseIcon.svelte' +import LumaAiIcon from './LumaAiIcon.svelte' +import BrexIcon from './BrexIcon.svelte' +import CloseIcon from './CloseIcon.svelte' +import RocketChatIcon from './RocketChatIcon.svelte' +import ApolloIcon from './ApolloIcon.svelte' +import BubbleIcon from './BubbleIcon.svelte' +import JoomlaIcon from './JoomlaIcon.svelte' +import MauticIcon from './MauticIcon.svelte' +import ZeroTierIcon from './ZeroTierIcon.svelte' +import SplitwiseIcon from './SplitwiseIcon.svelte' +import TelnyxIcon from './TelnyxIcon.svelte' +import MandrillIcon from './MandrillIcon.svelte' +import OpenWeatherIcon from './OpenWeatherIcon.svelte' +import YnabIcon from './YnabIcon.svelte' +import SpeechifyIcon from './SpeechifyIcon.svelte' +import ConvertKitIcon from './ConvertKitIcon.svelte' +import BrowserlessIcon from './BrowserlessIcon.svelte' import type { Component } from 'svelte' export const APP_TO_ICON_COMPONENT = { postgresql: PostgresIcon, @@ -194,6 +304,7 @@ export const APP_TO_ICON_COMPONENT = { pushover: PushoverIcon, quickbooks: QuickbooksIcon, ms_teams_webhook: MsTeamsIcon, + teams: MsTeamsIcon, mailgun: MailgunIcon, ipinfo: IpinfoIcon, gworkspace: GoogleIcon, @@ -213,7 +324,119 @@ export const APP_TO_ICON_COMPONENT = { apify: ApifyIcon, mcp: McpIcon, zoho: ZohoIcon, - pocketid: PocketIdIcon + pocketid: PocketIdIcon, + duckdb: DuckDbIcon, + activecampaign: ActiveCampaignIcon, + algolia: AlgoliaIcon, + bamboo_hr: BambooHrIcon, + baremetrics: BaremetricsIcon, + bitly: BitlyIcon, + blogger: BloggerIcon, + bluesky: BlueskyIcon, + box: BoxIcon, + brevo: BrevoIcon, + sendinblue: BrevoIcon, + buildkite: BuildkiteIcon, + calendly: CalendlyIcon, + circleci: CircleCiIcon, + cisco: CiscoIcon, + clerk: ClerkIcon, + cloudinary: CloudinaryIcon, + cockroachdb: CockroachDbIcon, + coda: CodaIcon, + coinbase: CoinbaseIcon, + coinmarketcap: CoinMarketCapIcon, + confluence: ConfluenceIcon, + contentful: ContentfulIcon, + datocms: DatoCmsIcon, + deepl: DeepLIcon, + digitalocean: DigitalOceanIcon, + discourse: DiscourseIcon, + docusign: DocusignIcon, + dropbox: DropboxIcon, + eventbrite: EventbriteIcon, + figma: FigmaIcon, + fly: FlyIcon, + freshdesk: FreshdeskIcon, + frontapp: FrontAppIcon, + ghostcms: GhostCmsIcon, + giphy: GiphyIcon, + gitbook: GitBookIcon, + honeybadger: HoneybadgerIcon, + ifttt: IftttIcon, + intercom: IntercomIcon, + line: LineIcon, + linear: LinearIcon, + linode: LinodeIcon, + medium: MediumIcon, + miro: MiroIcon, + mistral: MistralIcon, + mixpanel: MixpanelIcon, + monday: MondayIcon, + neondb: NeonDbIcon, + netlify: NetlifyIcon, + onesignal: OneSignalIcon, + pagerduty: PagerDutyIcon, + pandadoc: PandaDocIcon, + paypal: PaypalIcon, + personio: PersonioIcon, + pinterest: PinterestIcon, + pipedrive: PipedriveIcon, + planetscale: PlanetScaleIcon, + postmark: PostmarkIcon, + pusher: PusherIcon, + render: RenderIcon, + replicate: ReplicateIcon, + ringcentral: RingCentralIcon, + salesforce: SalesforceIcon, + segment: SegmentIcon, + sentry: SentryIcon, + servicenow: ServiceNowIcon, + shortcut: ShortcutIcon, + smartsheet: SmartsheetIcon, + strava: StravaIcon, + threads: ThreadsIcon, + todoist: TodoistIcon, + turso: TursoIcon, + twitch: TwitchIcon, + twitter: TwitterIcon, + vercel: VercelIcon, + webflow: WebflowIcon, + woocommerce: WooCommerceIcon, + wordpress: WordpressIcon, + xata: XataIcon, + yelp: YelpIcon, + youtube: YoutubeIcon, + zoom: ZoomIcon, + cohere: CohereIcon, + tally: TallyIcon, + clearbit: ClearbitIcon, + raindrop: RaindropIcon, + magento: MagentoIcon, + deel: DeelIcon, + groqai: GroqIcon, + togetherai: TogetherAiIcon, + runpod: RunPodIcon, + signoz: SigNozIcon, + readwise: ReadwiseIcon, + lumaai: LumaAiIcon, + git: GitIcon, + brex: BrexIcon, + close: CloseIcon, + rocketchat: RocketChatIcon, + apollo: ApolloIcon, + bubble: BubbleIcon, + joomla: JoomlaIcon, + mautic: MauticIcon, + zerotier: ZeroTierIcon, + splitwise: SplitwiseIcon, + telnyx: TelnyxIcon, + mandrill: MandrillIcon, + openweather: OpenWeatherIcon, + ynab: YnabIcon, + speechify: SpeechifyIcon, + convertkit: ConvertKitIcon, + browserless: BrowserlessIcon } as unknown as Record // to generate correct svelte package types export { @@ -314,5 +537,115 @@ export { MqttIcon, ApifyIcon, McpIcon, - ZohoIcon + ZohoIcon, + DuckDbIcon, + ActiveCampaignIcon, + AlgoliaIcon, + BambooHrIcon, + BaremetricsIcon, + BitlyIcon, + BloggerIcon, + BlueskyIcon, + BoxIcon, + BrevoIcon, + BuildkiteIcon, + CalendlyIcon, + CircleCiIcon, + CiscoIcon, + ClerkIcon, + CloudinaryIcon, + CockroachDbIcon, + CodaIcon, + CoinbaseIcon, + CoinMarketCapIcon, + ConfluenceIcon, + ContentfulIcon, + DatoCmsIcon, + DeepLIcon, + DigitalOceanIcon, + DiscourseIcon, + DocusignIcon, + DropboxIcon, + EventbriteIcon, + FigmaIcon, + FlyIcon, + FreshdeskIcon, + FrontAppIcon, + GhostCmsIcon, + GiphyIcon, + GitBookIcon, + HoneybadgerIcon, + IftttIcon, + IntercomIcon, + LineIcon, + LinearIcon, + LinodeIcon, + MediumIcon, + MiroIcon, + MistralIcon, + MixpanelIcon, + MondayIcon, + NeonDbIcon, + NetlifyIcon, + OneSignalIcon, + PagerDutyIcon, + PandaDocIcon, + PaypalIcon, + PersonioIcon, + PinterestIcon, + PipedriveIcon, + PlanetScaleIcon, + PostmarkIcon, + PusherIcon, + RenderIcon, + ReplicateIcon, + RingCentralIcon, + SalesforceIcon, + SegmentIcon, + SentryIcon, + ServiceNowIcon, + ShortcutIcon, + SmartsheetIcon, + StravaIcon, + ThreadsIcon, + TodoistIcon, + TursoIcon, + TwitchIcon, + TwitterIcon, + VercelIcon, + WebflowIcon, + WooCommerceIcon, + WordpressIcon, + XataIcon, + YelpIcon, + YoutubeIcon, + ZoomIcon, + CohereIcon, + TallyIcon, + ClearbitIcon, + RaindropIcon, + MagentoIcon, + DeelIcon, + GroqIcon, + TogetherAiIcon, + RunPodIcon, + SigNozIcon, + ReadwiseIcon, + LumaAiIcon, + BrexIcon, + CloseIcon, + RocketChatIcon, + ApolloIcon, + BubbleIcon, + JoomlaIcon, + MauticIcon, + ZeroTierIcon, + SplitwiseIcon, + TelnyxIcon, + MandrillIcon, + OpenWeatherIcon, + YnabIcon, + SpeechifyIcon, + ConvertKitIcon, + BrowserlessIcon } diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 5374f0c258..ca6b702795 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -51,6 +51,8 @@ export interface Setting { | 'otel' | 'otel_tracing_proxy' | 'secret_backend' + | 'github_enterprise_app' + | 'ws_connectivity' storage: SettingStorage advancedToggle?: { label: string @@ -193,6 +195,26 @@ export const settings: Record = { storage: 'setting', ee_only: '', hideInQuickSetup: true + }, + { + label: 'HTTP route workspace prefix', + description: + 'When enabled HTTP routes will be accessible at /api/r/{workspace_id}/{route} instead of /api/r/{route} allowing you to define same route path in different workspaces without conflict', + key: 'http_route_workspaced_route', + fieldType: 'boolean', + storage: 'setting', + ee_only: '', + hideInQuickSetup: true + }, + { + label: 'Audit log retention (days)', + key: 'audit_log_retention_days', + description: 'How long to keep audit log entries in the database. Default: 365 days.', + fieldType: 'number', + placeholder: '365', + storage: 'setting', + ee_only: '', + hideInQuickSetup: true } ], Jobs: [ @@ -599,6 +621,17 @@ export const settings: Record = { ee_only: '' } ], + Webhooks: [ + { + label: 'Instance Events Webhook', + description: + 'URL to receive POST requests for instance events (user added, OAuth signup, user invited/added/joined workspace).', + key: 'instance_events_webhook', + fieldType: 'text', + placeholder: 'https://example.com/webhook', + storage: 'setting' + } + ], 'OTEL/Prom': [ { label: 'OpenTelemetry', @@ -639,11 +672,10 @@ export const settings: Record = { Telemetry: [ { - label: 'Disable telemetry', + label: 'Minimal telemetry', key: 'disable_stats', fieldType: 'boolean', - storage: 'setting', - hiddenInEe: true + storage: 'setting' } ], 'Secret Storage': [ @@ -656,6 +688,40 @@ export const settings: Record = { storage: 'setting', ee_only: 'HashiCorp Vault integration is an Enterprise Edition feature' } + ], + 'GitHub App': [ + { + label: 'GitHub App', + description: + 'Configure a self-managed GitHub App to enable git sync without stats.windmill.dev.', + key: 'github_enterprise_app', + fieldType: 'github_enterprise_app', + storage: 'setting', + ee_only: '', + error: + 'When self-managed mode is enabled, Base URL, App ID, App Slug, Client ID, and Private Key are required.', + isValid: (v: any) => { + if (!v?.self_managed) return true + return !!(v?.base_url && v?.app_id && v?.app_slug && v?.client_id && v?.private_key) + } + } + ], + WebSocket: [ + { + label: 'WebSocket connectivity', + description: + 'Test connectivity to multiplayer, LSP, and debugger WebSocket services. Enable custom URL override for deployments where WebSocket traffic routes to a different host.', + key: 'ws_base_url', + fieldType: 'ws_connectivity', + storage: 'setting', + requiresReloadOnChange: true, + isValid: (value: string | undefined) => + !value || + (value.startsWith('ws') && + value.includes('://') && + !value.endsWith('/') && + !value.endsWith(' ')) + } ] } @@ -744,6 +810,12 @@ export const instanceSettingsNavigationGroups = [ aiDescription: 'Instance alerts settings', isEE: true }, + { + id: 'webhooks', + label: 'Webhooks', + aiId: 'instance-settings-webhooks', + aiDescription: 'Instance events webhook settings' + }, { id: 'otel_prom', label: 'OTEL/Prometheus', @@ -760,9 +832,27 @@ export const instanceSettingsNavigationGroups = [ } ] }, + { + title: 'AI', + items: [ + { + id: 'ai', + label: 'AI', + aiId: 'instance-settings-ai', + aiDescription: 'Instance AI settings (providers, models, prompts)' + } + ] + }, { title: 'Advanced', items: [ + { + id: 'github_enterprise_app', + label: 'GitHub App', + aiId: 'instance-settings-github-enterprise-app', + aiDescription: 'Self-managed GitHub App for git sync', + isEE: true + }, { id: 'private_hub', label: 'Private Hub', @@ -781,6 +871,12 @@ export const instanceSettingsNavigationGroups = [ label: 'Secret Storage', aiId: 'instance-settings-secret-storage', aiDescription: 'Instance secret storage settings' + }, + { + id: 'websocket', + label: 'WebSocket', + aiId: 'instance-settings-websocket', + aiDescription: 'WebSocket connectivity test and URL override' } ] } @@ -788,19 +884,23 @@ export const instanceSettingsNavigationGroups = [ export const tabToCategoryMap: Record = { general: 'Core', + ai: 'AI', sso: 'Auth/OAuth/SAML', oauth: 'Auth/OAuth/SAML', scim_saml: 'Auth/OAuth/SAML', smtp: 'SMTP', registries: 'Registries', alerts: 'Alerts', + webhooks: 'Webhooks', otel_prom: 'OTEL/Prom', indexer: 'Indexer', telemetry: 'Telemetry', secret_storage: 'Secret Storage', object_storage: 'Object Storage', jobs: 'Jobs', - private_hub: 'Private Hub' + private_hub: 'Private Hub', + github_enterprise_app: 'GitHub App', + websocket: 'WebSocket' } export const tabToAuthSubTab: Record = { @@ -819,17 +919,21 @@ export const setupNavigationGroups = instanceSettingsNavigationGroups export const categoryToTabMap: Record = { Core: 'general', + AI: 'ai', SMTP: 'smtp', 'Auth/OAuth/SAML': 'sso', Registries: 'registries', Alerts: 'alerts', + Webhooks: 'webhooks', 'OTEL/Prom': 'otel_prom', Indexer: 'indexer', Telemetry: 'telemetry', 'Secret Storage': 'secret_storage', 'Object Storage': 'object_storage', Jobs: 'jobs', - 'Private Hub': 'private_hub' + 'Private Hub': 'private_hub', + 'GitHub App': 'github_enterprise_app', + WebSocket: 'websocket' } export interface SearchableSettingItem { @@ -912,3 +1016,8 @@ export function buildSearchableSettingItems( return items } + +/** Registry settings that support per-workspace overrides. Excludes instance_python_version and uv_index_strategy which are instance-wide only. */ +export const WORKSPACE_REGISTRY_SETTINGS: Setting[] = settings['Registries'].filter( + (s) => s.key !== 'instance_python_version' && s.key !== 'uv_index_strategy' +) diff --git a/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte b/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte new file mode 100644 index 0000000000..3d6c314812 --- /dev/null +++ b/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte @@ -0,0 +1,171 @@ + + +
+ { + $values['github_enterprise_app'] = { + ...$values['github_enterprise_app'], + self_managed: !selfManaged + } + }} + /> + + {#if !selfManaged} +

+ Using the managed Windmill GitHub App via stats.windmill.dev. Enable self-managed mode to + configure your own GitHub App (required for GitHub Enterprise Server). +

+ {:else} +
+ How to create a GitHub App +
+

+ 1. On your GitHub instance, go to + Settings → Developer settings → GitHub Apps → New GitHub App. +

+

2. Fill in the required fields:

+
    +
  • + GitHub App name: e.g. windmill-sync (this becomes the app slug) +
  • +
  • + Homepage URL: your Windmill instance URL +
  • +
  • + Callback URL: <your-windmill-url>/gh_success +
  • +
  • + Setup URL (optional): + <your-windmill-url>/gh_success with "Redirect on update" checked +
  • +
  • Uncheck Active under Webhook (not needed)
  • +
+

3. Set repository permissions:

+
    +
  • Contents: Read & write
  • +
  • Metadata: Read-only
  • +
+

+ 4. Under "Where can this GitHub App be installed?", choose + Any account (or restrict to your organization). +

+

+ 5. Click Create GitHub App. On the next page, note the + App ID and Client ID. +

+

+ 6. Scroll down and click Generate a private key. Save + the downloaded .pem file — paste its contents into the Private Key field below. +

+

+ 7. The App Slug is the URL-friendly name shown in the + app's URL (e.g. github.com/apps/windmill-sync). +

+

+ 8. The Base URL is your GitHub instance root (e.g. + https://github.com or https://github.mycompany.com). +

+
+
+ {/if} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
diff --git a/frontend/src/lib/components/instanceSettings/IndexerMemorySettings.svelte b/frontend/src/lib/components/instanceSettings/IndexerMemorySettings.svelte index 52bc3cd9bc..eb37ac8a0c 100644 --- a/frontend/src/lib/components/instanceSettings/IndexerMemorySettings.svelte +++ b/frontend/src/lib/components/instanceSettings/IndexerMemorySettings.svelte @@ -2,7 +2,7 @@ import { Button } from '$lib/components/common' import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' import { IndexSearchService } from '$lib/gen' - import type { GetIndexerStatusResponse } from '$lib/gen' + import type { GetIndexerStatusResponse, GetIndexDiskStorageSizesResponse } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { displaySize } from '$lib/utils' import Tooltip from '../Tooltip.svelte' @@ -24,6 +24,7 @@ let clearServiceLogsIndexModalOpen = $state(false) let status: GetIndexerStatusResponse | undefined = $state(undefined) + let diskSizes: GetIndexDiskStorageSizesResponse | undefined = $state(undefined) let statusLoading = $state(true) let statusError = $state(false) @@ -41,9 +42,15 @@ statusLoading = true statusError = false try { - status = await IndexSearchService.getIndexerStatus() + const [statusRes, diskRes] = await Promise.all([ + IndexSearchService.getIndexerStatus(), + IndexSearchService.getIndexDiskStorageSizes().catch(() => undefined) + ]) + status = statusRes + diskSizes = diskRes } catch (e) { status = undefined + diskSizes = undefined statusError = true } finally { statusLoading = false @@ -139,7 +146,11 @@ : 'bg-red-500'}" > {label}: - + {entry?.is_alive ? 'Running' : 'Stopped'} {#if entry?.last_locked_at} @@ -161,21 +172,21 @@
Jobs index: - {#if status.job_indexer?.storage?.disk_size_bytes != null} - Disk: {displaySize(status.job_indexer.storage.disk_size_bytes) ?? 'N/A'} + {#if diskSizes?.job_index_disk_size_bytes != null} + Disk: {displaySize(diskSizes.job_index_disk_size_bytes) ?? 'N/A'} {/if} {#if status.job_indexer?.storage?.s3_size_bytes != null} - {#if status.job_indexer?.storage?.disk_size_bytes != null}·{/if} + {#if diskSizes?.job_index_disk_size_bytes != null}·{/if} S3: {displaySize(status.job_indexer.storage.s3_size_bytes) ?? 'N/A'} {/if} Service logs index: - {#if status.log_indexer?.storage?.disk_size_bytes != null} - Disk: {displaySize(status.log_indexer.storage.disk_size_bytes) ?? 'N/A'} + {#if diskSizes?.log_index_disk_size_bytes != null} + Disk: {displaySize(diskSizes.log_index_disk_size_bytes) ?? 'N/A'} {/if} {#if status.log_indexer?.storage?.s3_size_bytes != null} - {#if status.log_indexer?.storage?.disk_size_bytes != null}·{/if} + {#if diskSizes?.log_index_disk_size_bytes != null}·{/if} S3: {displaySize(status.log_indexer.storage.s3_size_bytes) ?? 'N/A'} {/if} diff --git a/frontend/src/lib/components/instanceSettings/InstanceAISettings.svelte b/frontend/src/lib/components/instanceSettings/InstanceAISettings.svelte new file mode 100644 index 0000000000..91b08eb46e --- /dev/null +++ b/frontend/src/lib/components/instanceSettings/InstanceAISettings.svelte @@ -0,0 +1,149 @@ + + +{#if loaded} + {#if showHubSync} +
+
+

Resource types

+

+ AI providers require their resource types. Sync from the Hub if they are missing. +

+
+ +
+ {#if hubSyncStatus === 'success'} +
+ + {hubSyncMessage} + +
+ {:else if hubSyncStatus === 'error'} +
+ + {hubSyncMessage} + +
+ {/if} + {/if} + + +{/if} diff --git a/frontend/src/lib/components/instanceSettings/WorkspaceRegistries.svelte b/frontend/src/lib/components/instanceSettings/WorkspaceRegistries.svelte new file mode 100644 index 0000000000..b468c49368 --- /dev/null +++ b/frontend/src/lib/components/instanceSettings/WorkspaceRegistries.svelte @@ -0,0 +1,309 @@ + + + + {#if workspaceIds.length === 0 && !showAddWorkspace} +

No workspace overrides configured.

+ {/if} + +
+ {#each workspaceIds as wsId (wsId)} + {@const wsName = workspaces.find((w) => w.id === wsId)?.name} + {@const isExpanded = expandedWorkspaces.has(wsId)} + {@const wsSettings = registries[wsId] ?? {}} + {@const activeKeys = Object.keys(wsSettings)} + {@const activeSettings = WORKSPACE_REGISTRY_SETTINGS.filter((s) => + activeKeys.includes(s.key) + )} +
+ + + {#if isExpanded} +
+ {#each activeSettings as setting (setting.key)} + {@const currentValue = wsSettings[setting.key]} +
+
+ {setting.label} +
+ {#if setting.fieldType === 'boolean'} + currentValue ?? false, (v) => updateSetting(wsId, setting.key, v, true) + } + /> + {:else if setting.fieldType === 'codearea'} + currentValue ?? '', (v) => updateSetting(wsId, setting.key, v)} + autoHeight + fixedOverflowWidgets={false} + /> + {:else if setting.fieldType === 'password'} + currentValue ?? '', (v) => updateSetting(wsId, setting.key, v) + } + placeholder={setting.placeholder} + /> + {:else} + updateSetting(wsId, setting.key, e.currentTarget.value)} + /> + {/if} +
+ {/each} + + {#if getAvailableFields(wsId).length > 0} + + {/if} +
+ {/if} +
+ {/each} +
+ + {#if showAddWorkspace} +
+ {#if availableWorkspaces.length > 0} + + {:else} + + {/if} + + +
+ {:else} +
+ +
+ {/if} +
diff --git a/frontend/src/lib/components/instanceSettings/WsConnectivityTest.svelte b/frontend/src/lib/components/instanceSettings/WsConnectivityTest.svelte new file mode 100644 index 0000000000..698e87d752 --- /dev/null +++ b/frontend/src/lib/components/instanceSettings/WsConnectivityTest.svelte @@ -0,0 +1,199 @@ + + +
+
+ +
+ + {#if results.length > 0} +
+ {#each results as result (result.name)} +
+ {result.name} + + {#if result.http === 'pending'} + + {:else if result.http === 'ok'} + + {:else} + + {/if} + HTTP + + + {#if result.ws === 'pending'} + + {:else if result.ws === 'ok'} + + {:else} + + {/if} + WebSocket + +
+ {/each} +
+ {/if} + +
+ +
+ + {#if enabled} + + {@const val = $values['ws_base_url']} + {#if val && (!val.startsWith('ws') || !val.includes('://') || val.endsWith('/') || val.endsWith(' '))} + + Must start with ws:// or wss:// and not end with / or a space + + {/if} + {/if} +
diff --git a/frontend/src/lib/components/mcp/McpScopeSelector.svelte b/frontend/src/lib/components/mcp/McpScopeSelector.svelte new file mode 100644 index 0000000000..9fe525dd5c --- /dev/null +++ b/frontend/src/lib/components/mcp/McpScopeSelector.svelte @@ -0,0 +1,444 @@ + + +
+
+ Scope + + {#snippet children({ item })} + + + + + {/snippet} + +
+ + {#if selectedMode === 'folder'} +
+ Select Folder + +
+ {/if} + +
+ Hub scripts (optional) + {#if loadingApps} +
Loading...
+ {:else if errorFetchApps} +
Error fetching apps
+ {:else} + + {/if} +
+ + {#if selectedMode === 'custom'} + {#if loadingRunnables} +
+ Loading scripts and flows... +
+ Loading... +
+
+ {:else} + {#snippet sectionHeader(label: string, selectAll: () => void, clearAll: () => void)} +
+ {label} +
+ + +
+
+ {/snippet} + +
+
+ {@render sectionHeader('Scripts', selectAllScripts, clearAllScripts)} + {#if allScripts.length > 0} + + {:else} +

No scripts available

+ {/if} +
+ +
+ {@render sectionHeader('Flows', selectAllFlows, clearAllFlows)} + {#if allFlows.length > 0} + + {:else} +

No flows available

+ {/if} +
+ +
+ {@render sectionHeader('API Endpoints', selectAllEndpoints, clearAllEndpoints)} + e.name))} + placeholder="Select endpoints" + bind:value={selectedEndpoints} + /> +
+ +
+ Selected: {selectedScripts.length} scripts, {selectedFlows.length} flows, {selectedEndpoints.length} + endpoints +
+ + +
+
+
+ Script wildcard patterns + + {#snippet text()} +
+

Add folder wildcards or complex patterns

+

Examples:

+
    +
  • f/folder/* - all scripts/flows in folder
  • +
  • f/folder1/*,f/folder2/* - multiple folders
  • +
  • Mix: f/folder/*,f/specific/path
  • +
+

+ Patterns are combined with individual selections above. +

+
+ {/snippet} + +
+
+ +
+
+
+ Flow wildcard patterns +
+ +
+
+
+ {/if} + {:else if selectedMode !== 'folder' || selectedFolder.length > 0} + {#if loadingRunnables} +
+ Scripts & Flows that will be available via MCP +
+ Loading... +
+
+ {:else} +
+ Scripts & Flows that will be available via MCP +
+ {#if includedRunnables.length > 0 && includedRunnables.length <= 5} + {#each includedRunnables as scriptOrFlow (scriptOrFlow)} + {scriptOrFlow} + {/each} + {:else if includedRunnables.length > 0} + {#each includedRunnables.slice(0, 3) as scriptOrFlow (scriptOrFlow)} + {scriptOrFlow} + {/each} + + +{includedRunnables.length - 3} more + + {:else} +

+ {warning} +

+ {/if} +
+ + API endpoint tools that will be available via MCP +
+ {#each mcpEndpointTools as endpoint (endpoint.name)} + + {#snippet text()} +
+
{endpoint.description}
+
+ {endpoint.method} + {endpoint.path} +
+
+ {/snippet} + {endpoint.name} +
+ {/each} +
+
+ {/if} + {/if} +
diff --git a/frontend/src/lib/components/meltComponents/Menu.svelte b/frontend/src/lib/components/meltComponents/Menu.svelte index e18e520141..879df7bc2c 100644 --- a/frontend/src/lib/components/meltComponents/Menu.svelte +++ b/frontend/src/lib/components/meltComponents/Menu.svelte @@ -10,6 +10,7 @@ import { twMerge } from 'tailwind-merge' import ResolveOpen from '$lib/components/common/menu/ResolveOpen.svelte' + import { watch } from 'runed' interface Props { placement?: Placement @@ -63,9 +64,10 @@ } = menu const sync = createSync(states) - $effect(() => { - sync.open(open, (v) => (open = Boolean(v))) - }) + watch( + () => open, + () => sync.open(open, (v) => (open = Boolean(v))) + ) export function close() { open = false diff --git a/frontend/src/lib/components/meltComponents/Popover.svelte b/frontend/src/lib/components/meltComponents/Popover.svelte index 96a0934611..a88d90c7ff 100644 --- a/frontend/src/lib/components/meltComponents/Popover.svelte +++ b/frontend/src/lib/components/meltComponents/Popover.svelte @@ -7,7 +7,7 @@ diff --git a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte index e45ea3b20b..296590a0e4 100644 --- a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte @@ -3,7 +3,7 @@ import RawAppBackgroundRunner from './RawAppBackgroundRunner.svelte' import type { Runnable } from './rawAppPolicy' import { htmlContent } from './utils' - import { onMount } from 'svelte' + import { onMount, untrack } from 'svelte' interface Props { workspace: string @@ -26,10 +26,14 @@ // Use blob URL instead of srcDoc to give the iframe a proper origin. // srcDoc iframes have "null" origin which breaks URL constructor in routers. + // untrack(user) so that userStore refreshes don't regenerate the blob URL + // and cause the iframe to fully reload (losing all state). + // The user context is only needed for initial render. let blobUrl = $derived.by(() => { if (!secret) return undefined + const u = untrack(() => user) const baseUrl = typeof window !== 'undefined' ? window.location.origin : '' - const html = htmlContent(workspace, secret, { ctx: user, workspace }, baseUrl, initialHash) + const html = htmlContent(workspace, secret, { ctx: u, workspace }, baseUrl, initialHash) const blob = new Blob([html], { type: 'text/html' }) return URL.createObjectURL(blob) }) diff --git a/frontend/src/lib/components/runs/JobRunsPreview.svelte b/frontend/src/lib/components/runs/JobRunsPreview.svelte index 274679d3bc..7827726891 100644 --- a/frontend/src/lib/components/runs/JobRunsPreview.svelte +++ b/frontend/src/lib/components/runs/JobRunsPreview.svelte @@ -50,11 +50,15 @@ if (!x || typeof x !== 'object') return {} const result: Record = {} for (const [k, v] of Object.entries(x)) { - if (!k.startsWith('_')) result[k] = v as WorkflowStatus + if (!k.startsWith('_') || k.startsWith('_step/')) result[k] = v as WorkflowStatus } return result } + function getStepResults(x: any): Record { + return x?._checkpoint?.completed_steps ?? {} + } + function handleFilterByConcurrencyKey(key: string) { dispatch('filterByConcurrencyKey', key) } @@ -156,6 +160,10 @@
{/if} diff --git a/frontend/src/lib/components/runs/RunBadges.svelte b/frontend/src/lib/components/runs/RunBadges.svelte index 7f32cbe588..197b939751 100644 --- a/frontend/src/lib/components/runs/RunBadges.svelte +++ b/frontend/src/lib/components/runs/RunBadges.svelte @@ -2,7 +2,7 @@ import Tooltip from '$lib/components/meltComponents/Tooltip.svelte' import PreprocessedArgsDisplay from '$lib/components/runs/PreprocessedArgsDisplay.svelte' import { truncateHash } from '$lib/utils' - import { base } from '$app/paths' + import { base } from '$lib/base' import { truncateRev } from '$lib/utils' import { workspaceStore } from '$lib/stores' import Badge from '$lib/components/common/badge/Badge.svelte' diff --git a/frontend/src/lib/components/runs/TimeframeSelect.svelte b/frontend/src/lib/components/runs/TimeframeSelect.svelte index feb223f213..cc5a99f70c 100644 --- a/frontend/src/lib/components/runs/TimeframeSelect.svelte +++ b/frontend/src/lib/components/runs/TimeframeSelect.svelte @@ -1,53 +1,11 @@ @@ -141,75 +149,81 @@ {#snippet content()}
{#if selectedTab === 'logs'} - - - {#if previewJob?.workflow_as_code_status} + {#if isWac} +
+ +
+ {:else} + + - - {/if} - - - - - {@render children?.()} - {#if showCustomResultPanel && customResultPanel} -
- {@render customResultPanel()} -
- {:else if previewJob != undefined && (previewJob.result_stream || previewJob.result)} -
-
- - {#snippet copilot_fix()} - {#if lang && editor && diffEditor && args && previewJob && !previewJob.success && getStringError(previewJob.result)} - - {/if} - {/snippet} - + + {@render children?.()} + {#if showCustomResultPanel && customResultPanel} +
+ {@render customResultPanel()}
-
- {:else} -
- - {#if previewIsLoading} - - {:else} - Test to see the result here - {/if} - - - The result renderer in Windmill supports rich display rendering, allowing you - to customize the display format of your results. - -
- {/if} - - - + {:else if previewJob != undefined && (previewJob.result_stream || previewJob.result)} +
+
+ + {#snippet copilot_fix()} + {#if lang && editor && diffEditor && args && previewJob && !previewJob.success && getStringError(previewJob.result)} + + {/if} + {/snippet} + +
+
+ {:else} +
+ + {#if previewIsLoading} + + {:else} + Test to see the result here + {/if} + + + The result renderer in Windmill supports rich display rendering, allowing + you to customize the display format of your results. + +
+ {/if} + + + + {/if} {/if} {#if selectedTab === 'history'}
@@ -312,9 +326,7 @@ {#if previewJob?.id} {:else} -
- Run a preview to see HTTP request traces -
+
Run a preview to see HTTP request traces
{/if} {/if}
diff --git a/frontend/src/lib/components/script_builder.ts b/frontend/src/lib/components/script_builder.ts index f224c3bd6a..ddc5a01b89 100644 --- a/frontend/src/lib/components/script_builder.ts +++ b/frontend/src/lib/components/script_builder.ts @@ -14,7 +14,7 @@ export interface ScriptBuilderProps { disableAi?: boolean fullyLoaded?: boolean initialPath?: string - template?: 'docker' | 'bunnative' | 'claudesandbox' | 'script' + template?: 'docker' | 'bunnative' | 'claudesandbox' | 'wac_python' | 'wac_typescript' | 'script' initialArgs?: Record lockedLanguage?: boolean showMeta?: boolean diff --git a/frontend/src/lib/components/scripts/CreateActionsScript.svelte b/frontend/src/lib/components/scripts/CreateActionsScript.svelte index 9c847c9a3a..48480918e0 100644 --- a/frontend/src/lib/components/scripts/CreateActionsScript.svelte +++ b/frontend/src/lib/components/scripts/CreateActionsScript.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/lib/components/scripts/WacExportDrawer.svelte b/frontend/src/lib/components/scripts/WacExportDrawer.svelte new file mode 100644 index 0000000000..59f1dca1b0 --- /dev/null +++ b/frontend/src/lib/components/scripts/WacExportDrawer.svelte @@ -0,0 +1,113 @@ + + + + + + drawer?.toggleDrawer()}> +
+ + + + {#snippet content()} +
+
+
+ {#key rawType} + + {/key} +
+ {/snippet} +
+
+
+
diff --git a/frontend/src/lib/components/scripts/scriptStore.svelte.ts b/frontend/src/lib/components/scripts/scriptStore.svelte.ts new file mode 100644 index 0000000000..abc1fb0d63 --- /dev/null +++ b/frontend/src/lib/components/scripts/scriptStore.svelte.ts @@ -0,0 +1,4 @@ +import type { NewScript } from '$lib/gen' +import { writable } from 'svelte/store' + +export const importScriptStore = writable(undefined) diff --git a/frontend/src/lib/components/select/SelectDropdown.svelte b/frontend/src/lib/components/select/SelectDropdown.svelte index 62a990bb46..d9fc2dcf70 100644 --- a/frontend/src/lib/components/select/SelectDropdown.svelte +++ b/frontend/src/lib/components/select/SelectDropdown.svelte @@ -117,7 +117,7 @@ ulClass )} > - {#each processedItems ?? [] as item, itemIndex (item.value)} + {#each processedItems ?? [] as item, itemIndex} {#if (item.__select_group && itemIndex === 0) || processedItems?.[itemIndex - 1]?.__select_group !== item.__select_group}
  • void onReset: () => void hasChanges: boolean - isWorkspaceSettings?: boolean + scope?: 'user' | 'workspace' | 'instance' } let { @@ -22,7 +22,7 @@ onSave, onReset, hasChanges, - isWorkspaceSettings = false + scope = 'user' }: Props = $props() const placeholders: Record = { @@ -63,9 +63,12 @@
    - {#if isWorkspaceSettings} + {#if scope === 'workspace'} Customize the system prompts for each AI mode. These prompts apply to all workspace members. + {:else if scope === 'instance'} + Customize the system prompts for each AI mode. These prompts apply to workspaces using + instance AI defaults. {:else} Customize the system prompts for each AI mode. These prompts are stored locally in your browser and apply in addition to workspace-level prompts. diff --git a/frontend/src/lib/components/settings/CloudQuotas.svelte b/frontend/src/lib/components/settings/CloudQuotas.svelte new file mode 100644 index 0000000000..61158f13ec --- /dev/null +++ b/frontend/src/lib/components/settings/CloudQuotas.svelte @@ -0,0 +1,167 @@ + + +
    +

    Cloud Quotas

    +

    + Current usage and limits for this workspace. Prune old versions to free up space. +

    + + {#if loading && !quotas} +

    Loading...

    + {:else if quotas} +
    + + + + + + + + + + {#each rows as row (row.key)} + {@const info = quotas[row.key]} + + + + + + {/each} + +
    ResourceUsageActions
    {row.label} + = info.limit ? 'text-red-500 font-semibold' : 'text-primary'} + > + {info.used} + + / {info.limit} + + {#if row.prunable && info.prunable > 0} + + {:else if row.prunable} + No old versions + {/if} +
    +
    + {/if} +
    + + + + {#if pruneTarget} +
    +

    + You are about to prune {getPrunableCount(pruneTarget)} + old {pruneTarget} versions. +

    +

    + {getPruneDescription(pruneTarget)} +

    +

    This action cannot be undone.

    +
    + {/if} + {#snippet actions()} + + {/snippet} +
    +
    diff --git a/frontend/src/lib/components/settings/CreateToken.svelte b/frontend/src/lib/components/settings/CreateToken.svelte index fbd9e41081..b4f273869b 100644 --- a/frontend/src/lib/components/settings/CreateToken.svelte +++ b/frontend/src/lib/components/settings/CreateToken.svelte @@ -1,29 +1,16 @@
    @@ -380,7 +135,7 @@ {#if scopes != undefined}
    Scope - {#each scopes as scope} + {#each scopes as scope (scope)} {/each}
    @@ -409,66 +164,18 @@
    {#if mcpCreationMode}
    - Scope - - {#snippet children({ item })} - - - - - {/snippet} - -
    - - {#if newMcpScope === 'folder'} -
    - Select Folder - -
    - {/if} - -
    - Hub scripts (optional) - {#if loadingApps} -
    Loading...
    - {:else if errorFetchApps} -
    Error fetching apps
    - {:else} - - {/if} +
    Workspace - + - {/key} - + {#if aiProviders[provider]} +
    + - -
    - - { - if (e.detail) { - codeCompletionModel = autocompleteModels[0] ?? '' - } else { - codeCompletionModel = undefined - } - }} - checked={codeCompletionModel != undefined} - disabled={autocompleteModels.length == 0} - options={{ - right: 'Enable code completion', - rightTooltip: 'We currently only support Mistral Codestral models for code completion.' - }} - /> + +
    + {/if} +
    + {/each} +
    - {#if codeCompletionModel != undefined} -
    - - + {/key} + - + +
    + + { + if (e.detail) { + codeCompletionModel = autocompleteModels[0] ?? '' + } else { + codeCompletionModel = undefined + } + }} + checked={codeCompletionModel != undefined} + disabled={autocompleteModels.length == 0} + options={{ + right: 'Enable code completion', + rightTooltip: 'We currently only support Mistral Codestral models for code completion.' + }} + /> + - -
    - - {#if promptCount > 0} - ({promptCount} configured) - {/if} - {#if hasPromptsChanges} - Unsaved changes + {#if codeCompletionModel != undefined} +
    + + - - - -
    - {/if} -
    - - {#if !automateUsernameCreation} + {#if isFork || creationStep === 'details'} + - {/if} - {#if !isFork} -
    + + {#if !automateUsernameCreation} + + {/if} + {#if !isFork} +
    + + + {#if isCloudHosted() && isDomainAllowed == false} +
    {domain} domain not allowed for auto-invite
    + {/if} + + {#if auto_invite} +
    + + {#if isCloudHosted()} + + {/if} + + +
    + {/if} +
    + {/if} + +
    + + {#if !forkCreationLoading} + + {:else} + + {/if} +
    + {:else} +
    + + Windmill AI powers the chat, code generation, flow creation, and code completion. Set + it up now or configure it later in Workspace settings. + + Learn more + + - + {#snippet children({ item })} @@ -517,7 +704,7 @@ type="password" autocomplete="new-password" bind:value={aiKey} - onkeyup={handleKeyUp} + onkeyup={handleAiKeyUp} /> {#if aiKey} -
    +
    {/if}
    -
    - +
    - {/if} -
    - - {#if !forkCreationLoading} + Skip for now + - {:else} - - {/if} -
    +
    + {/if}
    diff --git a/frontend/src/lib/components/workspaceSettings/CustomInstanceDbWizardModal.svelte b/frontend/src/lib/components/workspaceSettings/CustomInstanceDbWizardModal.svelte index 12338a4bae..c2e608e759 100644 --- a/frontend/src/lib/components/workspaceSettings/CustomInstanceDbWizardModal.svelte +++ b/frontend/src/lib/components/workspaceSettings/CustomInstanceDbWizardModal.svelte @@ -134,8 +134,7 @@ `GRANT CREATE ON DATABASE "${dbname}" TO custom_instance_user;\n` + 'ALTER DEFAULT PRIVILEGES IN SCHEMA public \n' + ' GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES\n TO custom_instance_user;\n' + - 'ALTER ROLE custom_instance_user CREATEROLE;\n' + - 'ALTER ROLE custom_instance_user REPLICATION;' + 'ALTER ROLE custom_instance_user CREATEROLE;' } ], status?.error ?? undefined diff --git a/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte b/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte index 378a3063a7..0b27b645b0 100644 --- a/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte @@ -190,7 +190,7 @@ let confirmationModal = createAsyncConfirmationModal() -
    +
    Ducklake
    diff --git a/frontend/src/lib/components/workspaceSettings/InstanceFallbackSettings.svelte b/frontend/src/lib/components/workspaceSettings/InstanceFallbackSettings.svelte new file mode 100644 index 0000000000..a44981ccbd --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/InstanceFallbackSettings.svelte @@ -0,0 +1,93 @@ + + +{#if instanceAiSummary} + +
    +

    + This workspace is currently using the instance AI defaults shown below. +

    + +
    + {#each sortedInstanceProviders as providerSummary} +
    +
    + + {getProviderLabel(providerSummary.provider)} + + Instance +
    +
    + {#each providerSummary.models as model} + {model} + {/each} +
    +
    + {/each} +
    + + {#if instanceAiSummary.default_model} +
    + Default chat model: + {instanceAiSummary.default_model.model} + + ({getProviderLabel(instanceAiSummary.default_model.provider)}) + +
    + {/if} + + {#if instanceAiSummary.code_completion_model} +
    + Code completion model: + + {instanceAiSummary.code_completion_model.model} + + + ({getProviderLabel(instanceAiSummary.code_completion_model.provider)}) + +
    + {/if} +
    +
    +{/if} + + +
    +

    + Create workspace-specific AI settings only if this workspace needs to override the active + instance defaults. +

    +
    + +
    +
    +
    diff --git a/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte b/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte index 44b9d5d1d3..ef46189444 100644 --- a/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte @@ -102,19 +102,6 @@ let hasUnsavedChanges = $derived.by(() => { return !deepEqual(s3ResourceSettings, s3ResourceSavedSettings) }) - - let volumeStorageItems: { value: string; label: string }[] = $derived.by(() => { - const items: { value: string; label: string }[] = [{ value: '', label: 'Disabled' }] - if (!emptyString(s3ResourceSettings.resourcePath)) { - items.push({ value: 'primary', label: 'Primary storage' }) - } - for (const [name, s] of s3ResourceSettings.secondaryStorage ?? []) { - if (!emptyString(s.resourcePath)) { - items.push({ value: name, label: name }) - } - } - return items - }) @@ -236,18 +223,18 @@ class="cursor-not-allowed" > {#snippet trigger()} - + - + {/snippet} {#snippet content()} - + {#if emptyString(tableRow[1].resourcePath)} Please select a storage resource {:else if isDirty(tableRow[0])} Please save your changes {/if} - + {/snippet} {:else} @@ -330,25 +317,6 @@ -
    - -
    - s3ResourceSettings.volumeStorage ?? '', + (v) => { + s3ResourceSettings.volumeStorage = v || undefined + } + } + /> +
    + + onDiscard?.()} + saveLabel="Save volume storage settings" + /> + {:else} + + You need to configure a workspace object storage before you can use volumes. + + + {/if} +{/if} diff --git a/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte b/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte index b8e2a9329f..ef58b2efd4 100644 --- a/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte +++ b/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte @@ -179,11 +179,10 @@ for (const serviceName of Object.keys(supportedServices)) { try { - const available = - await WorkspaceIntegrationService.checkInstanceSharingAvailable({ - workspace: $workspaceStore, - serviceName: serviceName as NativeServiceName - }) + const available = await WorkspaceIntegrationService.checkInstanceSharingAvailable({ + workspace: $workspaceStore, + serviceName: serviceName as NativeServiceName + }) instanceSharingAvailable[serviceName] = available } catch { instanceSharingAvailable[serviceName] = false @@ -389,7 +388,10 @@ Connected