From dcc20c4fc192b62ef39128d28a99802dccaad925 Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Wed, 30 Jul 2025 17:36:32 -0400 Subject: [PATCH] git sync UI improvements (#6303) * ui improvements round 1 * modal cleanup * init * UI refactor * UI cleanup + refactor * legacy cleanup * success model -> github actions, non-ee warnings * sqlx * npm check * ee warning everywhere * last comments * formatting * no hardcoded theme * claude review improvemenets --- ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...0cb549a34b96554ae1872355b90304f5dcb76.json | 4 +- ...508835affcb7679a48f2a443777e829bd1e74.json | 10 +- backend/windmill-api/openapi.yaml | 121 ++- backend/windmill-api/src/workspaces.rs | 294 ++++++- .../src/lib/components/DeployWorkspace.svelte | 2 +- .../src/lib/components/GitDiffPreview.svelte | 39 +- .../lib/components/InitGitRepoPopover.svelte | 454 ---------- .../components/MemoryFootprintViewer.svelte | 2 +- .../lib/components/PullGitRepoPopover.svelte | 418 ---------- .../src/lib/components/ResourcePicker.svelte | 12 +- .../lib/components/common/modal/Modal.svelte | 7 +- .../components/git_sync/DetectionFlow.svelte | 196 +++++ .../git_sync/GitSyncContext.svelte.ts | 583 +++++++++++++ .../git_sync/GitSyncModalManager.svelte | 108 +++ .../git_sync/GitSyncRepositoryCard.svelte | 361 ++++++++ .../git_sync/GitSyncRepositoryList.svelte | 19 + .../components/git_sync/GitSyncSection.svelte | 93 +++ .../git_sync/GitSyncSuccessModal.svelte | 69 ++ .../git_sync/PullWorkspaceModal.svelte | 538 ++++++++++++ .../git_sync/PushWorkspaceModal.svelte | 344 ++++++++ .../GitSyncFilterSettings.svelte | 678 +++------------ frontend/src/lib/git-sync.ts | 34 + frontend/src/lib/hubPaths.json | 5 +- frontend/src/lib/services/JobManager.ts | 165 ++++ .../(logged)/workspace_settings/+page.svelte | 787 +----------------- 26 files changed, 3063 insertions(+), 2282 deletions(-) delete mode 100644 frontend/src/lib/components/InitGitRepoPopover.svelte delete mode 100644 frontend/src/lib/components/PullGitRepoPopover.svelte create mode 100644 frontend/src/lib/components/git_sync/DetectionFlow.svelte create mode 100644 frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts create mode 100644 frontend/src/lib/components/git_sync/GitSyncModalManager.svelte create mode 100644 frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte create mode 100644 frontend/src/lib/components/git_sync/GitSyncRepositoryList.svelte create mode 100644 frontend/src/lib/components/git_sync/GitSyncSection.svelte create mode 100644 frontend/src/lib/components/git_sync/GitSyncSuccessModal.svelte create mode 100644 frontend/src/lib/components/git_sync/PullWorkspaceModal.svelte create mode 100644 frontend/src/lib/components/git_sync/PushWorkspaceModal.svelte create mode 100644 frontend/src/lib/git-sync.ts create mode 100644 frontend/src/lib/services/JobManager.ts diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 36ddb8ab9f..713ccb9dd3 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - true + null ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json b/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json index 54e94cfb8f..99269c9851 100644 --- a/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json +++ b/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json @@ -18,8 +18,8 @@ "Left": [] }, "nullable": [ - false, - true + true, + false ] }, "hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76" diff --git a/backend/.sqlx/query-fec6d5674dc6b5a6a0ece419c40508835affcb7679a48f2a443777e829bd1e74.json b/backend/.sqlx/query-fec6d5674dc6b5a6a0ece419c40508835affcb7679a48f2a443777e829bd1e74.json index 649ab85650..de2e819af2 100644 --- a/backend/.sqlx/query-fec6d5674dc6b5a6a0ece419c40508835affcb7679a48f2a443777e829bd1e74.json +++ b/backend/.sqlx/query-fec6d5674dc6b5a6a0ece419c40508835affcb7679a48f2a443777e829bd1e74.json @@ -41,11 +41,11 @@ ] }, "nullable": [ - false, - false, - false, - false, - false, + true, + true, + true, + true, + true, true ] }, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index dae9a64944..eae8ac4fd9 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -2455,6 +2455,65 @@ paths: application/json: schema: {} + /w/{workspace}/workspaces/edit_git_sync_repository: + post: + summary: add or update individual git sync repository + operationId: editGitSyncRepository + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + description: Git sync repository settings to add or update + required: true + content: + application/json: + schema: + type: object + properties: + git_repo_resource_path: + type: string + description: The resource path of the git repository to update + repository: + $ref: "#/components/schemas/GitRepositorySettings" + required: + - git_repo_resource_path + - repository + responses: + "200": + description: status + content: + application/json: + schema: {} + + /w/{workspace}/workspaces/delete_git_sync_repository: + delete: + summary: delete individual git sync repository + operationId: deleteGitSyncRepository + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + description: Git sync repository to delete + required: true + content: + application/json: + schema: + type: object + properties: + git_repo_resource_path: + type: string + description: The resource path of the git repository to delete + required: + - git_repo_resource_path + responses: + "200": + description: status + content: + application/json: + schema: {} + /w/{workspace}/workspaces/edit_deploy_ui_config: post: summary: edit workspace deploy ui settings @@ -13649,6 +13708,24 @@ components: customai, ] + GitSyncObjectType: + type: string + enum: + - script + - flow + - app + - folder + - resource + - variable + - secret + - resourcetype + - schedule + - user + - group + - trigger + - settings + - key + AIProviderModel: type: object properties: @@ -17134,15 +17211,7 @@ components: include_type: type: array items: - type: string - enum: - - script - - flow - - app - - resource - - variable - - secret - - trigger + $ref: "#/components/schemas/GitSyncObjectType" WorkspaceDefaultScripts: type: object @@ -17182,22 +17251,7 @@ components: include_type: type: array items: - type: string - enum: - - script - - flow - - app - - folder - - resource - - variable - - secret - - resourcetype - - schedule - - user - - group - - trigger - - settings - - key + $ref: "#/components/schemas/GitSyncObjectType" exclude_path: type: array items: @@ -17209,22 +17263,7 @@ components: exclude_types_override: type: array items: - type: string - enum: - - script - - flow - - app - - folder - - resource - - variable - - secret - - resourcetype - - schedule - - user - - group - - trigger - - settings - - key + $ref: "#/components/schemas/GitSyncObjectType" required: - script_path - git_repo_resource_path diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 8125d8d027..e6f59413c4 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -47,6 +47,8 @@ use windmill_common::{ oauth2::WORKSPACE_SLACK_BOT_TOKEN_PATH, utils::{paginate, rd_string, require_admin, Pagination}, }; +#[cfg(feature = "enterprise")] +use windmill_common::workspaces::GitRepositorySettings; use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; #[cfg(feature = "enterprise")] @@ -113,6 +115,8 @@ pub fn workspaced_service() -> Router { post(edit_large_file_storage_config), ) .route("/edit_git_sync_config", post(edit_git_sync_config)) + .route("/edit_git_sync_repository", post(edit_git_sync_repository)) + .route("/delete_git_sync_repository", delete(delete_git_sync_repository)) .route("/edit_deploy_ui_config", post(edit_deploy_ui_config)) .route("/edit_default_app", post(edit_default_app)) .route("/default_app", get(get_default_app)) @@ -869,6 +873,76 @@ pub struct EditGitSyncConfig { pub git_sync_settings: Option, } +#[cfg(feature = "enterprise")] +#[derive(Deserialize, Debug)] +pub struct EditGitSyncRepository { + pub git_repo_resource_path: String, + pub repository: GitRepositorySettings, +} + +#[cfg(feature = "enterprise")] +#[derive(Deserialize, Debug)] +pub struct DeleteGitSyncRepositoryRequest { + pub git_repo_resource_path: String, +} + +#[cfg(feature = "enterprise")] +fn validate_git_repo_resource_path(path: &str) -> Result<()> { + // Resource paths should follow the pattern: $res:f// or $res:u// + if path.is_empty() { + return Err(Error::BadRequest("Resource path cannot be empty".to_string())); + } + + // Must start with $res: prefix + if !path.starts_with("$res:") { + return Err(Error::BadRequest("Resource path must start with '$res:'".to_string())); + } + + // Extract the actual path after $res: + let actual_path = &path[5..]; // Remove "$res:" prefix + + // Basic validation: must start with f/ or u/ and contain at least one slash + if !actual_path.starts_with("f/") && !actual_path.starts_with("u/") { + return Err(Error::BadRequest("Resource path must start with '$res:f/' or '$res:u/'".to_string())); + } + + // Must have at least 3 parts (type, folder/user, name) + let parts: Vec<&str> = actual_path.split('/').collect(); + if parts.len() < 3 || parts.iter().any(|part| part.is_empty()) { + return Err(Error::BadRequest("Invalid resource path format".to_string())); + } + + // Resource name validation (last part) + let resource_name = parts.last().unwrap(); + if !resource_name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') { + return Err(Error::BadRequest("Resource name can only contain alphanumeric characters, underscores, and hyphens".to_string())); + } + + Ok(()) +} + +#[cfg(feature = "enterprise")] +fn cleanup_legacy_git_sync_settings_in_memory( + git_sync_settings: &mut windmill_common::workspaces::WorkspaceGitSyncSettings, + workspace_id: &str, +) { + // Check if all repositories are in new format (have settings field) + let all_repos_migrated = git_sync_settings.repositories.iter() + .all(|repo| repo.settings.is_some()); + + // If all repos are migrated and we still have legacy workspace-level settings + if all_repos_migrated && (git_sync_settings.include_path.is_some() || git_sync_settings.include_type.is_some()) { + tracing::info!( + workspace_id = workspace_id, + "All git sync repositories migrated to new format, cleaning up legacy workspace-level settings" + ); + + // Remove workspace-level legacy fields + git_sync_settings.include_path = None; + git_sync_settings.include_type = None; + } +} + #[cfg(not(feature = "enterprise"))] async fn edit_git_sync_config( _authed: ApiAuthed, @@ -905,7 +979,10 @@ async fn edit_git_sync_config( ) .await?; - if let Some(git_sync_settings) = new_config.git_sync_settings { + if let Some(mut git_sync_settings) = new_config.git_sync_settings { + // Clean up legacy workspace-level settings if all repos are migrated + cleanup_legacy_git_sync_settings_in_memory(&mut git_sync_settings, &w_id); + let serialized_config = serde_json::to_value::(git_sync_settings) .map_err(|err| Error::internal_err(err.to_string()))?; @@ -924,6 +1001,7 @@ async fn edit_git_sync_config( .execute(&mut *tx) .await?; } + tx.commit().await?; // Trigger git sync for git sync settings changes @@ -941,6 +1019,220 @@ async fn edit_git_sync_config( Ok(format!("Edit git sync config for workspace {}", &w_id)) } +#[cfg(not(feature = "enterprise"))] +async fn edit_git_sync_repository( + _authed: ApiAuthed, + Extension(_db): Extension, + Path(_w_id): Path, + Json(_new_config): Json, +) -> Result { + return Err(Error::BadRequest( + "Git sync is only available on Windmill Enterprise Edition".to_string(), + )); +} + +#[cfg(feature = "enterprise")] +async fn edit_git_sync_repository( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + ApiAuthed { is_admin, username, .. }: ApiAuthed, + Json(new_config): Json, +) -> Result { + require_admin(is_admin, &username)?; + + // Validate the resource path format + validate_git_repo_resource_path(&new_config.git_repo_resource_path)?; + + let mut tx = db.begin().await?; + + // First, get the current git sync settings + let current_settings = sqlx::query!( + "SELECT git_sync FROM workspace_settings WHERE workspace_id = $1", + &w_id + ) + .fetch_optional(&mut *tx) + .await?; + + let mut git_sync_settings = if let Some(row) = current_settings { + if let Some(git_sync) = row.git_sync { + serde_json::from_value::(git_sync) + .map_err(|err| Error::internal_err(err.to_string()))? + } else { + WorkspaceGitSyncSettings::default() + } + } else { + WorkspaceGitSyncSettings::default() + }; + + // Audit log before we move the repository + audit_log( + &mut *tx, + &authed, + "workspaces.edit_git_sync_repository", + ActionKind::Update, + &w_id, + Some(&authed.email), + Some([("repository_path", new_config.git_repo_resource_path.as_str()), ("repository_data", &format!("{:?}", new_config.repository))].into()), + ) + .await?; + + // Check if repository exists before modifying + let repo_exists = git_sync_settings.repositories.iter() + .any(|repo| repo.git_repo_resource_path == new_config.git_repo_resource_path); + + // Find and update the specific repository, or add it if it doesn't exist + let repo_found = git_sync_settings.repositories.iter_mut() + .find(|repo| repo.git_repo_resource_path == new_config.git_repo_resource_path); + + if let Some(existing_repo) = repo_found { + // Update existing repository + *existing_repo = new_config.repository; + } else { + // Repository doesn't exist, add it as a new repository + git_sync_settings.repositories.push(new_config.repository); + } + + // Clean up legacy workspace-level settings if all repos are migrated + cleanup_legacy_git_sync_settings_in_memory(&mut git_sync_settings, &w_id); + + // Save the updated configuration + let serialized_config = serde_json::to_value::(git_sync_settings) + .map_err(|err| Error::internal_err(err.to_string()))?; + + sqlx::query!( + "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", + serialized_config, + &w_id + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + // Trigger git sync for individual repository update/add + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { setting_type: "git_sync".to_string() }, + Some(format!("Git sync repository '{}' {}", + new_config.git_repo_resource_path, + if repo_exists { "updated" } else { "added" } + )), + false, + ) + .await?; + + Ok(format!("{} git sync repository '{}' for workspace {}", + if repo_exists { "Updated" } else { "Added" }, + new_config.git_repo_resource_path, + &w_id + )) +} + +#[cfg(not(feature = "enterprise"))] +async fn delete_git_sync_repository( + _authed: ApiAuthed, + Extension(_db): Extension, + Path(_w_id): Path, + Json(_request): Json, +) -> Result { + return Err(Error::BadRequest( + "Git sync is only available on Windmill Enterprise Edition".to_string(), + )); +} + +#[cfg(feature = "enterprise")] +async fn delete_git_sync_repository( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + ApiAuthed { is_admin, username, .. }: ApiAuthed, + Json(request): Json, +) -> Result { + require_admin(is_admin, &username)?; + + // Validate the resource path format + validate_git_repo_resource_path(&request.git_repo_resource_path)?; + + let mut tx = db.begin().await?; + + // First, get the current git sync settings + let current_settings = sqlx::query!( + "SELECT git_sync FROM workspace_settings WHERE workspace_id = $1", + &w_id + ) + .fetch_optional(&mut *tx) + .await?; + + let mut git_sync_settings = if let Some(row) = current_settings { + if let Some(git_sync) = row.git_sync { + serde_json::from_value::(git_sync) + .map_err(|err| Error::internal_err(err.to_string()))? + } else { + WorkspaceGitSyncSettings::default() + } + } else { + WorkspaceGitSyncSettings::default() + }; + + // Check if repository exists and remove it + let original_count = git_sync_settings.repositories.len(); + git_sync_settings.repositories.retain(|repo| repo.git_repo_resource_path != request.git_repo_resource_path); + + if git_sync_settings.repositories.len() == original_count { + return Err(Error::BadRequest(format!( + "Repository with path '{}' not found in git sync configuration", + request.git_repo_resource_path + ))); + } + + // Audit log + audit_log( + &mut *tx, + &authed, + "workspaces.delete_git_sync_repository", + ActionKind::Delete, + &w_id, + Some(&authed.email), + Some([("repository_path", request.git_repo_resource_path.as_str())].into()), + ) + .await?; + + // Clean up legacy workspace-level settings if all repos are migrated + cleanup_legacy_git_sync_settings_in_memory(&mut git_sync_settings, &w_id); + + // Save the updated configuration + let serialized_config = serde_json::to_value::(git_sync_settings) + .map_err(|err| Error::internal_err(err.to_string()))?; + + sqlx::query!( + "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", + serialized_config, + &w_id + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + // Trigger git sync for repository deletion + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { setting_type: "git_sync".to_string() }, + Some(format!("Git sync repository '{}' deleted", request.git_repo_resource_path)), + false, + ) + .await?; + + Ok(format!("Deleted git sync repository '{}' from workspace {}", request.git_repo_resource_path, &w_id)) +} + #[derive(Debug, Deserialize)] struct EditDeployUIConfig { #[cfg(feature = "enterprise")] diff --git a/frontend/src/lib/components/DeployWorkspace.svelte b/frontend/src/lib/components/DeployWorkspace.svelte index b4938cd927..9636f78921 100644 --- a/frontend/src/lib/components/DeployWorkspace.svelte +++ b/frontend/src/lib/components/DeployWorkspace.svelte @@ -583,7 +583,7 @@
{#if !$enterpriseLicense} - Deploy to staging/prod from the web UI is only available with an enterprise license {:else if notSet == true} diff --git a/frontend/src/lib/components/GitDiffPreview.svelte b/frontend/src/lib/components/GitDiffPreview.svelte index 8d602831af..6d821e7d6c 100644 --- a/frontend/src/lib/components/GitDiffPreview.svelte +++ b/frontend/src/lib/components/GitDiffPreview.svelte @@ -1,35 +1,26 @@
Preview of changes:
- {#if !previewResult?.added?.length && !previewResult?.deleted?.length && !previewResult?.modified?.length && !previewResult?.yamlModified} + + {#if !added.length && !deleted.length && !edited.length}
No changes found! The workspace is up to date.
{:else} - {#if previewResult?.yamlModified} -
-
Modified:
-
    -
  • wmill.yaml (Git sync settings)
  • -
-
- {/if} - {#if previewResult?.added?.length} + {#if added.length}
Added:
    - {#each previewResult.added as file} + {#each added as file}
  • {file}{!file.includes('.') ? ' (dir)' : ''}
  • @@ -37,11 +28,11 @@
{/if} - {#if previewResult?.deleted?.length} + {#if deleted.length}
Deleted:
    - {#each previewResult.deleted as file} + {#each deleted as file}
  • {file}{!file.includes('.') ? ' (dir)' : ''}
  • @@ -49,11 +40,11 @@
{/if} - {#if previewResult?.modified?.length} + {#if edited.length}
-
Modified:
+
Edited:
    - {#each previewResult.modified as file} + {#each edited as file}
  • {file}{!file.includes('.') ? ' (dir)' : ''}
  • diff --git a/frontend/src/lib/components/InitGitRepoPopover.svelte b/frontend/src/lib/components/InitGitRepoPopover.svelte deleted file mode 100644 index a22d46b394..0000000000 --- a/frontend/src/lib/components/InitGitRepoPopover.svelte +++ /dev/null @@ -1,454 +0,0 @@ - - - - - - - -
    -
    -

    Push workspace to Git repository

    -
    - This action will push all workspace objects that match your current filter settings to the - selected branch in your Git repository. Any existing content in the branch will be replaced with the - filtered workspace content. - - -
    - - - {#if isCliInfoExpanded} -
    -
    - Not familiar with Windmill CLI? Check out the docs -
    -
    -
    npm install -g windmill-cli
    -wmill workspace add {$workspaceStore} {$workspaceStore} {`${$page.url.protocol}//${$page.url.hostname}/`}
    -wmill init
    -# adjust wmill.yaml file configuraton as needed
    -wmill sync pull
    -git add -A
    -git commit -m 'Initial commit'
    -git push
    -
    -
    - {/if} -
    -
    -
    - -
    - -
    - Enter a new branch name to push to (e.g so you can merge back into main with a pull - request). If left blank, the default branch from the git repository resource will be used. -
    -
    - -
    -
    - - {#if previewResult} - - {/if} - - {#if previewJobId} -
    - {#if previewJobStatus === 'running'} - - {:else if previewJobStatus === 'success'} - - {:else if previewJobStatus === 'failure'} - - {/if} - Preview job: - - {previewJobId} - -
    - {/if} - - {#if pushJobId} -
    - {#if pushJobStatus === 'running'} - - {:else if pushJobStatus === 'success'} - - {:else if pushJobStatus === 'failure'} - - {/if} - Push job: - - {pushJobId} - -
    - {/if} - - -
    - -
    - {#if !previewResult} - - {:else} - - {#if previewResult.added?.length || previewResult.deleted?.length || previewResult.modified?.length} - - {/if} - {/if} -
    -
    - - {#if initResult?.message} -
    - - {initResult.message} - -
    - {/if} -
    -
    -
    diff --git a/frontend/src/lib/components/MemoryFootprintViewer.svelte b/frontend/src/lib/components/MemoryFootprintViewer.svelte index b1aac05f8f..afcdfb82a4 100644 --- a/frontend/src/lib/components/MemoryFootprintViewer.svelte +++ b/frontend/src/lib/components/MemoryFootprintViewer.svelte @@ -71,7 +71,7 @@
    {#if !$enterpriseLicense} - + Job metrics are only available on Windmill Enterprise Edition. {:else if (jobMemoryStats?.length ?? 0) === 0} diff --git a/frontend/src/lib/components/PullGitRepoPopover.svelte b/frontend/src/lib/components/PullGitRepoPopover.svelte deleted file mode 100644 index 57e9e71cdd..0000000000 --- a/frontend/src/lib/components/PullGitRepoPopover.svelte +++ /dev/null @@ -1,418 +0,0 @@ - - - - - - - - -
    -
    -

    Pull workspace from Git repository

    -
    - This action will pull all workspace objects from your Git repository according to the - filters set in the Git repository wmill.yaml file and apply those filter settings to the - workspace. - - This will overwrite your current workspace content and Git sync - filter settings with the content from the Git repository. - - - -
    - - - {#if isCliInfoExpanded} -
    -
    - Not familiar with Windmill CLI? Check out the docs -
    -
    -
    npm install -g windmill-cli
    -# Clone your git repository
    -git clone $REPO_URL
    -cd $REPO_NAME
    -# Configure Windmill CLI
    -wmill workspace add {$workspaceStore} {$workspaceStore} {`${$page.url.protocol}//${$page.url.hostname}/`}
    -# Push the content to Windmill
    -wmill sync push --yes
    -# Optional: add --skip-secrets --skip-variables --skip-resources flags as needed
    -
    -
    - {/if} -
    -
    -
    - - {#if previewResult} - - {/if} - - {#if jobStatus.id} -
    - {#if jobStatus.status === 'running'} - - {:else if jobStatus.status === 'success'} - - {:else if jobStatus.status === 'failure'} - - {/if} - {jobStatus.type === 'preview' ? 'Preview' : 'Pull'} job: - - {jobStatus.id} - -
    - {#if jobStatus.error} -
    {jobStatus.error}
    - {/if} - {/if} - -
    - -
    - {#if !previewResult} - - {:else} - - {#if previewResult.added?.length || previewResult.deleted?.length || previewResult.modified?.length} - - {/if} - {/if} -
    -
    -
    -
    -
    diff --git a/frontend/src/lib/components/ResourcePicker.svelte b/frontend/src/lib/components/ResourcePicker.svelte index 6a897b36ee..4389e0b87f 100644 --- a/frontend/src/lib/components/ResourcePicker.svelte +++ b/frontend/src/lib/components/ResourcePicker.svelte @@ -25,6 +25,7 @@ defaultValues?: Record | undefined placeholder?: string | undefined onClear?: () => void + excludedValues?: string[] } let { @@ -39,7 +40,8 @@ expressOAuthSetup = false, defaultValues = undefined, placeholder = undefined, - onClear = undefined + onClear = undefined, + excludedValues = undefined }: Props = $props() if (initialValue && value == undefined) { @@ -104,6 +106,7 @@ const nc = resources .flat() .filter((x) => x.resource_type != 'state' && x.resource_type != 'cache') + .filter((x) => !excludedValues || !excludedValues.includes(x.path)) .map((x) => ({ value: x.path, label: x.path, @@ -140,6 +143,13 @@ untrack(() => loadResources(resourceType)) }) + $effect(() => { + excludedValues + if ($workspaceStore && resourceType && !disabled) { + untrack(() => loadResources(resourceType)) + } + }) + let appConnect: AppConnect | undefined = $state() let resourceEditor: ResourceEditorDrawer | undefined = $state() let dbManagerDrawer: DbManagerDrawer | undefined = $state() diff --git a/frontend/src/lib/components/common/modal/Modal.svelte b/frontend/src/lib/components/common/modal/Modal.svelte index 47a2fe09a0..795885b44c 100644 --- a/frontend/src/lib/components/common/modal/Modal.svelte +++ b/frontend/src/lib/components/common/modal/Modal.svelte @@ -2,7 +2,6 @@ import { createEventDispatcher } from 'svelte' import { fade } from 'svelte/transition' import Button from '../button/Button.svelte' - import Badge from '../badge/Badge.svelte' import { twMerge } from 'tailwind-merge' import CloseButton from '../CloseButton.svelte' @@ -38,7 +37,7 @@ } - + {#if open} @@ -98,9 +97,7 @@ color="light" size="sm" > - {cancelText ?? 'Cancel'}Escape + {cancelText ?? 'Cancel'}
    {/if} diff --git a/frontend/src/lib/components/git_sync/DetectionFlow.svelte b/frontend/src/lib/components/git_sync/DetectionFlow.svelte new file mode 100644 index 0000000000..db826c5ec6 --- /dev/null +++ b/frontend/src/lib/components/git_sync/DetectionFlow.svelte @@ -0,0 +1,196 @@ + + +{#if repo} +
    + {#if !repo.detectionState || repo.detectionState === 'idle'} + +
    + +
    + {:else if repo.detectionState === 'loading'} + +
    + + Checking repository... +
    + {:else if repo.detectionState === 'no-wmill'} + + + No git sync configuration found. Configure your sync settings below. + + + + + +
    + + + +
    + + +
    + +
    + {:else if repo.detectionState === 'has-wmill'} + + + Found existing git sync configuration. Settings loaded from repository. + + + + + +
    + + + +
    + + +
    + +
    + {:else if repo.detectionState === 'error'} + + + {repo.detectionError || 'Failed to check repository'} + + {/if} + + + {#if repo.detectionJobId && (repo.detectionState === 'loading' || repo.detectionState === 'error')} +
    + {#if repo.detectionJobStatus === 'running'} + + {:else if repo.detectionJobStatus === 'success'} + + {:else if repo.detectionJobStatus === 'failure'} + + {/if} + Detection job: + + {repo.detectionJobId} + +
    + {/if} +
    +{/if} diff --git a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts new file mode 100644 index 0000000000..16b313b0e6 --- /dev/null +++ b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts @@ -0,0 +1,583 @@ +import { getContext, setContext } from 'svelte' +import { JobService, WorkspaceService } from '$lib/gen' +import type { GitRepositorySettings as BackendGitRepositorySettings, GitSyncObjectType } from '$lib/gen' +import { jobManager } from '$lib/services/JobManager' +import hubPaths from '$lib/hubPaths.json' +import type { SettingsObject } from '$lib/git-sync' + +export type GitSyncRepository = BackendGitRepositorySettings & { + settings: SettingsObject + exclude_types_override: GitSyncObjectType[] + legacyImported?: boolean + isUnsavedConnection?: boolean + collapsed?: boolean + // Repository detection state for new connections + detectionState?: 'idle' | 'loading' | 'no-wmill' | 'has-wmill' | 'error' + extractedSettings?: SettingsObject + detectionError?: string + // Job tracking for detection + detectionJobId?: string + detectionJobStatus?: 'running' | 'success' | 'failure' + // Internal tracking for resource path changes + _trackedPath?: string +} + +export type GitSyncTestJob = { + jobId: string + status: 'running' | 'success' | 'failure' | undefined +} + +export type GitSyncSettings = { + repositories: GitSyncRepository[] +} + +export type ModalState = { + push: { idx: number, repo: GitSyncRepository, open: boolean } | null + pull: { idx: number, repo: GitSyncRepository, open: boolean, settingsOnly?: boolean } | null + success: { open: boolean, savedWithoutInit?: boolean } | null +} + +export type ValidationState = { + isValid: boolean + isDuplicate: boolean + hasChanges: boolean +} + +const GIT_SYNC_CONTEXT_KEY = Symbol('git-sync-context') + +// Context implementation +export function createGitSyncContext(workspace: string) { + const repositories = $state([]) + const initialRepositories = $state([]) + const gitSyncTestJobs = $state([]) + let loading = $state(false) + const activeModals = $state({ push: null, pull: null, success: null }) + + // Legacy workspace-level settings state + const legacyWorkspaceIncludePath = $state([]) + const legacyWorkspaceIncludeType = $state([]) + + // Derived state for legacy detection + const hasWorkspaceLevelSettings = $derived( + legacyWorkspaceIncludePath.length > 0 || legacyWorkspaceIncludeType.length > 0 + ) + + // Watch for changes to git repository paths and reset detection state + $effect(() => { + repositories.forEach((repo) => { + if (repo.isUnsavedConnection) { + const currentPath = repo.git_repo_resource_path + + if (repo._trackedPath && repo._trackedPath !== currentPath && repo.detectionState && repo.detectionState !== 'idle') { + _resetRepoDetectionState(repo) + } + + repo._trackedPath = currentPath + } + }) + }) + + const getValidationStates = () => { + return repositories.map((repo, idx) => ({ + isValid: validateRepository(repo, idx), + isDuplicate: checkDuplicate(repo, idx), + hasChanges: checkChanges(repo, idx) + })) + } + + const getHasAnyChanges = () => { + const validationStates = getValidationStates() + + // Check if any individual repositories have changes + const individualChanges = validationStates.some(v => v.hasChanges) + + // Check if any legacy repos were imported + const anyLegacyImported = repositories.some(r => r.legacyImported) + + // Check if the set of repositories has changed (added/removed repos) + const repositorySetChanged = (() => { + if (loading) { + return false + } + + if (!initialRepositories || initialRepositories.length === 0) { + return repositories.filter((_,i) => validationStates[i]?.isValid).length > 0 + } + + const initialValidPaths = new Set( + initialRepositories + .filter(r => r.git_repo_resource_path && r.git_repo_resource_path.trim() !== '') + .map(r => r.git_repo_resource_path) + ) + const currentValidPaths = new Set( + repositories + .filter((_,i) => validationStates[i]?.isValid) + .map(r => r.git_repo_resource_path) + ) + + // Check if sets are different (repos added or removed) + return initialValidPaths.size !== currentValidPaths.size || + [...initialValidPaths].some(path => !currentValidPaths.has(path)) || + [...currentValidPaths].some(path => !initialValidPaths.has(path)) + })() + + return individualChanges || anyLegacyImported || repositorySetChanged + } + + const getAllRepositoriesValid = () => getValidationStates().every(v => v.isValid) + const getHasUnsavedConnections = () => repositories.some(repo => repo.isUnsavedConnection) + + function validateRepository(repo: GitSyncRepository, idx: number): boolean { + if (!repo.git_repo_resource_path) return false + return !checkDuplicate(repo, idx) + } + + function checkDuplicate(repo: GitSyncRepository, idx: number): boolean { + if (!repo.git_repo_resource_path) return false + const firstIdx = repositories.findIndex(r => r.git_repo_resource_path === repo.git_repo_resource_path) + return firstIdx !== -1 && firstIdx < idx + } + + function checkChanges(repo: GitSyncRepository, idx: number): boolean { + const initial = initialRepositories[idx] + if (!initial) return true + + // Legacy repositories always have "changes" because they need migration + if (repo.legacyImported) return true + + return JSON.stringify(serializeRepository(repo)) !== JSON.stringify(serializeRepository(initial)) + } + + function serializeRepository(repo: GitSyncRepository) { + return { + git_repo_resource_path: repo.git_repo_resource_path, + script_path: repo.script_path, + use_individual_branch: repo.use_individual_branch, + group_by_folder: repo.group_by_folder, + settings: repo.settings, + exclude_types_override: repo.exclude_types_override + } + } + + function addRepository() { + repositories.push({ + git_repo_resource_path: '', + script_path: hubPaths.gitSync, + use_individual_branch: false, + group_by_folder: false, + settings: { + include_path: ['f/**'], + exclude_path: [], + extra_include_path: [], + include_type: ['script', 'flow', 'app', 'folder'] + }, + exclude_types_override: [], + legacyImported: false, + isUnsavedConnection: true, + collapsed: false + }) + gitSyncTestJobs.push({ + jobId: '', + status: undefined + }) + } + + async function removeRepository(idx: number) { + const repo = repositories[idx] + if (!repo) return + + // Check if this repository exists in the initial (saved) state + const existsInInitialState = initialRepositories.some( + initialRepo => initialRepo.git_repo_resource_path === repo.git_repo_resource_path + ) + + // Only call backend API if repository exists in the saved state + if (existsInInitialState && repo.git_repo_resource_path) { + await WorkspaceService.deleteGitSyncRepository({ + workspace, + requestBody: { + git_repo_resource_path: `$res:${repo.git_repo_resource_path}` + } + }) + + // Update initial state to remove the deleted repository + const initialIdx = initialRepositories.findIndex( + initialRepo => initialRepo.git_repo_resource_path === repo.git_repo_resource_path + ) + if (initialIdx !== -1) { + initialRepositories.splice(initialIdx, 1) + } + } + + // Remove from local state + repositories.splice(idx, 1) + gitSyncTestJobs.splice(idx, 1) + } + + function getRepository(idx: number) { + return repositories[idx] + } + + function showPushModal(idx: number) { + const repo = repositories[idx] + if (repo) { + activeModals.push = { idx, repo, open: true } + } + } + + function showPullModal(idx: number, settingsOnly = false) { + const repo = repositories[idx] + if (repo) { + activeModals.pull = { idx, repo, open: true, settingsOnly } + } + } + + function closeModal(type: 'push' | 'pull' | 'success') { + if (activeModals[type]) { + activeModals[type]!.open = false + } + setTimeout(() => { + activeModals[type] = null + }, 200) + } + + function closePushModal() { + closeModal('push') + } + + function closePullModal() { + closeModal('pull') + } + + function showSuccessModal(savedWithoutInit?: boolean) { + activeModals.success = { open: true, savedWithoutInit } + } + + function closeSuccessModal() { + closeModal('success') + } + + function getValidation(idx: number): ValidationState { + const states = getValidationStates() + return states[idx] || { isValid: false, isDuplicate: false, hasChanges: false } + } + + async function detectRepository(idx: number) { + const repo = repositories[idx] + if (!repo || !repo.git_repo_resource_path) { + throw new Error('Repository not found or no resource path') + } + + repo.detectionState = 'loading' + repo.detectionError = undefined + repo.detectionJobId = undefined + repo.detectionJobStatus = undefined + + try { + const jobId = await JobService.runScriptByPath({ + workspace, + path: hubPaths.gitInitRepo, + requestBody: { + workspace_id: workspace, + repo_url_resource_path: repo.git_repo_resource_path, + dry_run: true, + pull: false, + only_wmill_yaml: true, + settings_json: JSON.stringify(repo.settings) + }, + skipPreprocessor: true + }) + + repo.detectionJobId = jobId + repo.detectionJobStatus = 'running' + + // Use JobManager for polling - result will be the actual job response + await jobManager.runWithProgress( + () => Promise.resolve(jobId), + { + workspace, + timeout: 30000, + timeoutMessage: 'Detection job timed out after 30s', + onProgress: (status) => { + repo.detectionJobStatus = status.status + + // Process successful detection result + if (status.status === 'success' && status.result) { + const response = status.result as any + if (response.isInitialSetup) { + repo.detectionState = 'no-wmill' + } else { + repo.detectionState = 'has-wmill' + // Apply extracted settings from the git repository + if (response.local) { + repo.extractedSettings = response.local + // Auto-apply the extracted settings + repo.settings = { ...response.local } + } + } + } else if (status.status === 'failure') { + repo.detectionState = 'error' + repo.detectionError = status.error || 'Detection failed' + } + } + } + ) + } catch (error: any) { + repo.detectionState = 'error' + repo.detectionError = error?.message || error?.toString() || 'Failed to detect repository' + repo.detectionJobStatus = 'failure' + } + } + + // Settings management + async function loadSettings() { + loading = true + try { + const settings = await WorkspaceService.getSettings({ workspace }) + + if (settings.git_sync !== undefined && settings.git_sync !== null) { + // Detect workspace-level legacy settings (outside repositories) + const workspaceLegacyIncludePath: string[] = (settings.git_sync as any)?.include_path ?? [] + const workspaceLegacyIncludeTypeRaw: GitSyncObjectType[] = (settings.git_sync as any)?.include_type ?? [] + const workspaceLegacyIncludeType: GitSyncObjectType[] = [...workspaceLegacyIncludeTypeRaw] + + // Update legacy workspace state + legacyWorkspaceIncludePath.splice(0, legacyWorkspaceIncludePath.length, ...workspaceLegacyIncludePath) + legacyWorkspaceIncludeType.splice(0, legacyWorkspaceIncludeType.length, ...workspaceLegacyIncludeType) + + if (settings.git_sync.repositories) { + repositories.splice(0, repositories.length, ...settings.git_sync.repositories.map(repo => { + // Check if this is a legacy repo (no nested settings object) + const isRepoLegacy = !repo.settings + const repoExcludeTypesOverride = repo.exclude_types_override ?? [] + + // Determine default types - use workspace legacy or fallback + const defaultTypes: GitSyncObjectType[] = workspaceLegacyIncludeType.length > 0 + ? [...workspaceLegacyIncludeType] + : ['script', 'flow', 'app', 'folder'] + + let repoSettings: SettingsObject + if (isRepoLegacy) { + // Legacy repo: inherit from workspace-level settings and apply exclude_types_override + const inheritedIncludeType = repo.settings?.include_type ?? [...defaultTypes] + const effectiveIncludeType = repoExcludeTypesOverride.length > 0 + ? inheritedIncludeType.filter(type => !repoExcludeTypesOverride.includes(type)) + : inheritedIncludeType + + repoSettings = { + include_path: repo.settings?.include_path ?? [...workspaceLegacyIncludePath], + exclude_path: repo.settings?.exclude_path ?? [], + extra_include_path: repo.settings?.extra_include_path ?? [], + include_type: effectiveIncludeType + } + } else { + // New format: use repo's own settings + repoSettings = { + include_path: repo.settings?.include_path ?? ['f/**'], + exclude_path: repo.settings?.exclude_path ?? [], + extra_include_path: repo.settings?.extra_include_path ?? [], + include_type: repo.settings?.include_type ?? ['script', 'flow', 'app'] + } + } + + return { + ...repo, + git_repo_resource_path: repo.git_repo_resource_path.replace('$res:', ''), + settings: repoSettings, + exclude_types_override: repoExcludeTypesOverride, + // Mark legacy repos for UI handling + legacyImported: isRepoLegacy + } + })) + } + } + + // Store initial state for change tracking + initialRepositories.splice(0, initialRepositories.length, ...repositories.map(repo => ({ ...repo }))) + } finally { + loading = false + } + } + + + // Migration utility for legacy repositories + function migrateLegacyRepository(repo: GitSyncRepository): GitSyncRepository { + if (!repo.legacyImported) { + return repo // Already migrated or not legacy + } + + // Create migrated repository - exclude_types_override should already be applied in settings.include_type + // from the loadSettings logic, so we just need to clear the override and mark as migrated + return { + ...repo, + exclude_types_override: [], // Clear the override since it's now integrated into include_type + legacyImported: false // Mark as migrated + } + } + + async function saveRepository(idx: number, savedWithoutInit = false) { + const repo = repositories[idx] + if (!repo || !validateRepository(repo, idx)) { + throw new Error('Cannot save invalid repository') + } + + // Migrate legacy repository if needed + const repoToSave = repo.legacyImported ? migrateLegacyRepository(repo) : repo + + // Use the new individual repository API instead of saving all repositories + await WorkspaceService.editGitSyncRepository({ + workspace, + requestBody: { + git_repo_resource_path: `$res:${repoToSave.git_repo_resource_path}`, + repository: { + git_repo_resource_path: `$res:${repoToSave.git_repo_resource_path}`, + script_path: repoToSave.script_path, + use_individual_branch: repoToSave.use_individual_branch, + group_by_folder: repoToSave.group_by_folder, + settings: repoToSave.settings, + exclude_types_override: repoToSave.exclude_types_override + } + } + }) + + // Update local state with migrated repository + repositories[idx] = repoToSave + initialRepositories[idx] = { ...repoToSave } + + // Update local state + if (repoToSave.isUnsavedConnection) { + repoToSave.isUnsavedConnection = false + repoToSave.detectionState = undefined + repoToSave.extractedSettings = undefined + // Show success modal for new connections + showSuccessModal(savedWithoutInit) + } + } + + + + // Helper functions for original functionality + + function revertRepository(idx: number) { + const initial = initialRepositories[idx] + if (initial) { + repositories[idx] = JSON.parse(JSON.stringify(initial)) + } + } + + + + // Reset detection state for a repository + function resetDetectionState(idx: number) { + const repo = repositories[idx] + if (!repo || !repo.isUnsavedConnection) return + + _resetRepoDetectionState(repo) + } + + // Helper function to reset detection state on a repository object + function _resetRepoDetectionState(repo: GitSyncRepository) { + repo.detectionState = 'idle' + repo.extractedSettings = undefined + repo.detectionError = undefined + repo.detectionJobId = undefined + repo.detectionJobStatus = undefined + } + + + async function runTestJob(idx: number) { + const repo = repositories[idx] + if (!repo?.git_repo_resource_path || !repo?.script_path) { + return + } + + try { + const jobId = await JobService.runScriptByPath({ + workspace, + path: hubPaths.gitSyncTest, + requestBody: { + repo_url_resource_path: repo.git_repo_resource_path + }, + skipPreprocessor: true + }) + + gitSyncTestJobs[idx] = { + jobId: jobId, + status: 'running' + } + + // Use JobManager for polling + await jobManager.runWithProgress( + () => Promise.resolve(jobId), + { + workspace, + timeout: 5000, + timeoutMessage: 'Git sync test job timed out after 5s', + onProgress: (status) => { + gitSyncTestJobs[idx].status = status.status === 'success' ? 'success' : + status.status === 'failure' ? 'failure' : 'running' + } + } + ) + + // If we get here, the job completed successfully + gitSyncTestJobs[idx].status = 'success' + } catch (error) { + gitSyncTestJobs[idx].status = 'failure' + } + } + + // Return context object + return { + // State (read-only access) + get repositories() { return repositories }, + get loading() { return loading }, + get activeModals() { return activeModals }, + get gitSyncTestJobs() { return gitSyncTestJobs }, + get initialRepositories() { return initialRepositories }, + get legacyWorkspaceIncludePath() { return legacyWorkspaceIncludePath }, + get legacyWorkspaceIncludeType() { return legacyWorkspaceIncludeType }, + + // Computed states - use getter functions that compute on access + get validationStates() { return getValidationStates() }, + get hasAnyChanges() { return getHasAnyChanges() }, + get allRepositoriesValid() { return getAllRepositoriesValid() }, + get hasUnsavedConnections() { return getHasUnsavedConnections() }, + get hasWorkspaceLevelSettings() { return hasWorkspaceLevelSettings }, + + // Methods + addRepository, + removeRepository, + getRepository, + getValidation, + revertRepository, + runTestJob, + resetDetectionState, + detectRepository, + migrateLegacyRepository, + showPushModal, + showPullModal, + closePushModal, + closePullModal, + showSuccessModal, + closeSuccessModal, + loadSettings, + saveRepository, + } +} + +export type GitSyncContextType = ReturnType + +export function setGitSyncContext(workspace: string): GitSyncContextType { + const context = createGitSyncContext(workspace) + setContext(GIT_SYNC_CONTEXT_KEY, context) + return context +} + +export function getGitSyncContext(): GitSyncContextType { + const context = getContext(GIT_SYNC_CONTEXT_KEY) + if (!context) { + throw new Error('Git sync context not found. Make sure to call setGitSyncContext first.') + } + return context +} diff --git a/frontend/src/lib/components/git_sync/GitSyncModalManager.svelte b/frontend/src/lib/components/git_sync/GitSyncModalManager.svelte new file mode 100644 index 0000000000..9da776cb96 --- /dev/null +++ b/frontend/src/lib/components/git_sync/GitSyncModalManager.svelte @@ -0,0 +1,108 @@ + + + +{#if gitSyncContext.activeModals.push} + {@const { idx, repo } = gitSyncContext.activeModals.push} + {@const isNewConnection = repo.isUnsavedConnection && repo.detectionState === 'no-wmill'} + handleSaveWithoutInit(idx) : undefined} + /> +{/if} + + +{#if gitSyncContext.activeModals.pull} + {@const { idx, repo, settingsOnly } = gitSyncContext.activeModals.pull} + handleFilterUpdate(idx, filters)} + onSettingsSaved={handleSettingsSaved} + onSuccess={handlePullSuccess} + {settingsOnly} + /> +{/if} + + +{#if gitSyncContext.activeModals.success} + +{/if} diff --git a/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte b/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte new file mode 100644 index 0000000000..d6d92d2aca --- /dev/null +++ b/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte @@ -0,0 +1,361 @@ + + +{#if repo} +
    + +
    +
    + Repository #{idx + 1} + {#if repo.legacyImported} + + Legacy Configuration + + {/if} + + {repo.git_repo_resource_path} + +
    +
    + {#if validation.hasChanges && validation.isValid && !repo.isUnsavedConnection} + + {#if gitSyncContext.initialRepositories[idx] && !repo.legacyImported} + + {/if} + {/if} + + {#if !confirmingDelete} + + {:else} +
    + + +
    + {/if} +
    +
    + {#if !repo.collapsed} +
    +
    + {#key repo} +
    Resource:
    + + {#if !emptyString(repo.git_repo_resource_path)} + + {/if} + {/key} +
    + + {#if !emptyString(repo.git_repo_resource_path)} +
    + {#if validation.isDuplicate} + This resource is already used by another repository. + {/if} + {#if gitSyncTestJob && gitSyncTestJob.status !== undefined} + {#if gitSyncTestJob.status === 'running'} + + {:else if gitSyncTestJob.status === 'success'} + + {:else} + + {/if} + Git sync resource checked via Windmill job + + {gitSyncTestJob.jobId} + WARNING: Only read permissions are verified. + {/if} +
    + + {#if repo.legacyImported} + + This repository was initialized from workspace-level legacy Git-Sync settings. Review the filters and press Save to migrate. + + {/if} +
    + {#if repo} + {#if repo.script_path != hubPaths.gitSync} + + The git sync version for this repository is not latest. Current: {repo.script_path}, latest: + {hubPaths.gitSync} +
    + +
    +
    + {/if} + {#if repo.isUnsavedConnection && !emptyString(repo.git_repo_resource_path)} + +
    + +
    + {:else} + + + {#snippet actions()} + + {/snippet} + + {/if} + + {#if !repo.isUnsavedConnection} +
    +
    + + + +
    + + + {#if !emptyString(repo.git_repo_resource_path) && !repo.legacyImported} +
    +
    Manual workspace content sync
    +
    + + +
    +
    + {/if} +
    + + {/if} + + {/if} +
    + {:else} +
    Please select a Git repository resource.
    + {/if} +
    + {/if} +
    +{/if} diff --git a/frontend/src/lib/components/git_sync/GitSyncRepositoryList.svelte b/frontend/src/lib/components/git_sync/GitSyncRepositoryList.svelte new file mode 100644 index 0000000000..c664dfed40 --- /dev/null +++ b/frontend/src/lib/components/git_sync/GitSyncRepositoryList.svelte @@ -0,0 +1,19 @@ + + +
    + {#if gitSyncContext.repositories.length === 0} + + Add your first Git repository to start syncing your workspace. + + {:else} + {#each gitSyncContext.repositories as repository, idx (repository.git_repo_resource_path || idx)} + + {/each} + {/if} +
    diff --git a/frontend/src/lib/components/git_sync/GitSyncSection.svelte b/frontend/src/lib/components/git_sync/GitSyncSection.svelte new file mode 100644 index 0000000000..2f65e8e75a --- /dev/null +++ b/frontend/src/lib/components/git_sync/GitSyncSection.svelte @@ -0,0 +1,93 @@ + + +{#if !gitSyncContext} +
    +
    Loading workspace...
    +
    +{:else if gitSyncContext.loading} +
    +
    Loading git sync settings...
    +
    +{:else} +
    +
    +
    Git Sync
    + + Connect the Windmill workspace to a Git repository to automatically commit and push + scripts, flows, and apps to the repository on each deploy. + +
    + + Only new changes matching the filters will trigger a git sync. You still need to initialize + the repo to the desired state first. + +
    + {#if !$enterpriseLicense} +
    + + + Automatically saving scripts to a Git repository on each deploy is a Windmill EE feature. + +
    + {/if} + {#if $enterpriseLicense && gitSyncContext.repositories != undefined} +
    + +
    +
    + + + + + +
    + +
    + + + + {/if} +{/if} diff --git a/frontend/src/lib/components/git_sync/GitSyncSuccessModal.svelte b/frontend/src/lib/components/git_sync/GitSyncSuccessModal.svelte new file mode 100644 index 0000000000..5beddd05c6 --- /dev/null +++ b/frontend/src/lib/components/git_sync/GitSyncSuccessModal.svelte @@ -0,0 +1,69 @@ + + + +
    + +
    +
    + +
    +
    +

    Git sync connection saved successfully!

    +

    Your repository is now configured to receive changes from Windmill.

    +
    +
    + + + {#if savedWithoutInit} +
    +

    Repository saved without initialization

    +

    + Only new changes will be pushed to this repository. Existing content in Windmill has not been initialized to the repository. +

    +
    + {/if} + + +
    +

    + + Optional: Enable automatic deployment from Git to Windmill +

    +

    + To automatically deploy changes from your Git repository back to Windmill (when PRs are merged), you can set up GitHub Actions or similar CI/CD workflows. +

    +
    +

    This setup enables:

    +
      +
    • Automatic deployment to Windmill when PRs are merged
    • +
    • Full bidirectional sync between Git and Windmill
    • +
    + +
    +
    + +
    +
    diff --git a/frontend/src/lib/components/git_sync/PullWorkspaceModal.svelte b/frontend/src/lib/components/git_sync/PullWorkspaceModal.svelte new file mode 100644 index 0000000000..370a5f0981 --- /dev/null +++ b/frontend/src/lib/components/git_sync/PullWorkspaceModal.svelte @@ -0,0 +1,538 @@ + + + + +
    + +

    + {#if settingsOnly} + Pull and apply settings changes from the Git repository to your workspace. This will update your sync filter settings only. + {:else} + Pull and apply changes from the Git repository to your workspace. If settings changes are detected, you can choose to pull just the settings or everything. + {/if} +

    + + + {#if !settingsOnly} + + Pulling from the repository will overwrite any local changes to files that exist in the repository. + Make sure to preview the changes before applying. + + {/if} + + + {#if !previewResult} +
    + +
    + {/if} + + + {#if previewJobId} +
    + {#if previewJobStatus === 'running'} + + {:else if previewJobStatus === 'success'} + + {:else if previewJobStatus === 'failure'} + + {/if} + Preview job: + + {previewJobId} + +
    + {/if} + + + {#if previewError} + + {previewError} + + {/if} + + + {#if previewResult && !previewError} + {@const settingsChanges = getSettingsChanges(previewResult)} + {@const workspaceChanges = getWorkspaceChanges(previewResult)} +
    + + {#if settingsChanges.hasChanges && !settingsApplied} +
    +

    + Filter Settings from Repository + wmill.yaml +

    + +
    + {#if settingsChanges.diff?.diff} + {#each Object.entries(settingsChanges.diff.diff) as [field, change]} + {@const fieldName = field.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())} + {@const typedChange = change as {from: any, to: any}} +
    + {fieldName}: + {#if Array.isArray(typedChange.from) && Array.isArray(typedChange.to)} + {typedChange.from.length === 0 ? 'None' : typedChange.from.join(', ')} + + {typedChange.to.length === 0 ? 'None' : typedChange.to.join(', ')} + {:else} + {typedChange.from} + + {typedChange.to} + {/if} +
    + {/each} + {:else} +
    + Settings changes detected but no detailed diff available. +
    + {/if} +
    +
    + {/if} + + + {#if settingsOnly && !settingsChanges.hasChanges} +
    +
    + No settings changes detected. Your local sync filter settings are already up to date with the repository. +
    +
    + {/if} + + + {#if !settingsOnly && !settingsChanges.hasChanges && workspaceChanges.hasChanges} +
    +

    Workspace changes to pull

    + + {#if workspaceChanges.hasChanges} + + {:else} +
    +
    No changes to pull from the repository.
    +
    + {/if} +
    + {/if} +
    + {/if} + + + {#if previewResult && !previewError} + {@const settingsChanges = getSettingsChanges(previewResult)} + {@const workspaceChanges = getWorkspaceChanges(previewResult)} + + {#if settingsChanges.hasChanges || workspaceChanges.hasChanges} +
    + {#if settingsChanges.hasChanges && workspaceChanges.hasChanges && !settingsApplied} + +
    +
    Step 1 of 2: Apply settings changes
    +
    Settings changes detected. Apply these first to ensure workspace content is pulled with the correct configuration.
    +
    + +
    +
    + {:else if settingsChanges.hasChanges && !workspaceChanges.hasChanges && !settingsApplied} + +
    + +
    + {:else if workspaceChanges.hasChanges && (!settingsChanges.hasChanges || settingsApplied)} + +
    + {#if settingsApplied} +
    Step 2 of 2: Pull Workspace Changes
    +
    ✓ Settings applied successfully. Now you can pull the workspace changes.
    + {/if} +
    + +
    +
    + {:else} + +
    +
    No changes to pull from the repository.
    +
    + {/if} +
    + {/if} + {/if} + + + {#if applyJobId} +
    + {#if applyJobStatus === 'running'} + + {:else if applyJobStatus === 'success'} + + {:else if applyJobStatus === 'failure'} + + {/if} + Pull job: + + {applyJobId} + +
    + {/if} + + + {#if applyError} + + {applyError} + + {/if} + + +
    + + + {#if showCliInstructions} +
    +
    +# Setup (only needed if local folder not initialized yet)
    +npm install -g windmill-cli
    +wmill workspace add {$workspaceStore} {$workspaceStore} {window.location.origin}
    +wmill init --workspace {$workspaceStore} --repository {gitRepoResourcePath}
    +
    +{#if !settingsOnly}
    +# Push from git repository to workspace
    +wmill sync push --workspace {$workspaceStore} --repository {gitRepoResourcePath}
    +
    +{/if}
    +# Push settings only from git repository
    +wmill gitsync-settings push --workspace {$workspaceStore} --repository {gitRepoResourcePath}
    +
    + {/if} +
    +
    +
    diff --git a/frontend/src/lib/components/git_sync/PushWorkspaceModal.svelte b/frontend/src/lib/components/git_sync/PushWorkspaceModal.svelte new file mode 100644 index 0000000000..3719982254 --- /dev/null +++ b/frontend/src/lib/components/git_sync/PushWorkspaceModal.svelte @@ -0,0 +1,344 @@ + + + + +
    + +

    Push your current workspace content to the connected Git repository based on the configured filters.

    +

    Note: This will not update git sync settings in wmill.yaml. Settings can only be pulled from the repository as it is the source of truth.

    + + + {#if isNewConnection} +
    +

    Settings that will be pushed to repository

    +
    +
    Include paths: {uiState.include_path?.join(', ') || 'None'}
    +
    Exclude paths: {uiState.exclude_path?.join(', ') || 'None'}
    + {#if uiState.extra_include_path?.length > 0} +
    Extra include paths: {uiState.extra_include_path.join(', ')}
    + {/if} +
    Include types: {uiState.include_type?.join(', ') || 'None'}
    +
    +

    To modify these settings, cancel and configure them in the workspace settings.

    +
    + {/if} + + + {#if !previewResult} +
    + +
    + {/if} + + + {#if previewJobId} +
    + {#if previewJobStatus === 'running'} + + {:else if previewJobStatus === 'success'} + + {:else if previewJobStatus === 'failure'} + + {/if} + Preview job: + + {previewJobId} + +
    + {/if} + + + {#if previewError} + + {previewError} + + {/if} + + + {#if previewResult && !previewError} +
    +

    Changes to Push

    + + {#if previewResult.changes?.length > 0} + + {:else} +
    +
    No changes to push to the repository.
    +
    + {/if} +
    + {/if} + + + {#if previewResult && !previewError} + {@const hasChanges = previewResult.changes?.length > 0} + {#if hasChanges} +
    +
    + + {#if isNewConnection && onSaveWithoutInit} + + {/if} +
    +
    + {/if} + {/if} + + + {#if applyJobId} +
    + {#if applyJobStatus === 'running'} + + {:else if applyJobStatus === 'success'} + + {:else if applyJobStatus === 'failure'} + + {/if} + Push job: + + {applyJobId} + +
    + {/if} + + + {#if applyError} + + {applyError} + + {/if} + + +
    + + + {#if showCliInstructions} +
    +
    +# Setup (only needed if local folder not initialized yet)
    +npm install -g windmill-cli
    +wmill workspace add {$workspaceStore} {$workspaceStore} {window.location.origin}
    +wmill init --workspace {$workspaceStore} --repository {gitRepoResourcePath}
    +
    +# Pull workspace content to git repository
    +wmill sync pull --workspace {$workspaceStore} --repository {gitRepoResourcePath}
    +
    + {/if} +
    +
    +
    diff --git a/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte b/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte index e4b91f4df2..5e42152c8f 100644 --- a/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte @@ -1,31 +1,11 @@
    @@ -526,18 +116,19 @@ codebases: []`
    Git Sync filter settings + {#if isLegacyRepo} + + This repository uses legacy configuration format and inherits settings from workspace-level defaults. Excluded types are filtered out from inherited types. Save to migrate to the new format. + + {:else if !isEditable} + + These settings are controlled by the wmill.yaml file in your git repository. Click "Pull from repo" to check for settings drift and pull settings from repo. + + {/if}
    - {#if !collapsed} - - {/if}
    {#if !collapsed} - {#if editAsYaml} -
    - - {#if yamlError} -
    {yamlError}
    - {/if} -
    - {:else} + {#if isEditable} +
    @@ -753,124 +334,105 @@ codebases: []`
    -
    -
    - - - {isPullMode ? 'Pull settings from Git repository' : 'Push settings to Git repository'} - +
    +
    + {isInitialSetup ? 'Configure initial sync settings' : 'Review migration settings'}
    -
    - - {#if previewResult?.hasChanges && (previewResult?.isInitialSetup || (previewResult?.diff && Object.keys(previewResult.diff).length > 0))} - + + Update settings with CLI + {#if showCliInstructions} + + {:else} + + {/if} + + + {#if showCliInstructions} +
    +
    + These filter settings are sourced from the wmill.yaml file in your git repository. + To modify them, edit the file in your repository, commit the changes, and sync using these commands: +
    +
    +# Make sure your repo is up to date
    +git pull
    +
    +# Edit wmill.yaml file
    +vim wmill.yaml
    +
    +# Push changes to workspace
    +wmill gitsync-settings push --workspace {$workspaceStore} --repository {git_repo_resource_path}
    +
    +# Commit changes
    +git add wmill.yaml
    +git commit
    +git push
    +
    {/if}
    - {#if previewError} -
    {previewError}
    - {/if} - {#if previewJobId} -
    - {#if previewJobStatus === 'running'} - - {:else if previewJobStatus === 'success'} - - {:else if previewJobStatus === 'failure'} - - {/if} - Preview job: - {previewJobId} -
    - {/if} - {#if previewResult} -
    -
    Preview of changes:
    - {#if previewResult.isInitialSetup} -
    - {previewResult.message || 'wmill.yaml will be created with repository settings'} -
    - {:else if previewResult.hasChanges && previewResult.diff && Object.keys(previewResult.diff).length > 0} -
    - {#each Object.entries(previewResult.diff) as [field, change]} -
    - {field}: -
    - {#if Array.isArray(change.from) || Array.isArray(change.to)} -
    -
    - {JSON.stringify(change.from)}
    -
    + {JSON.stringify(change.to)}
    -
    - {:else} - {JSON.stringify(change.from)} - - {JSON.stringify(change.to)} - {/if} -
    -
    - {/each} -
    - {:else} -
    No changes found! The file is up to date.
    - {/if} -
    - {/if} - {#if pushJobId} -
    - {#if pushJobStatus === 'running'} - - {:else if pushJobStatus === 'success'} - - {:else if pushJobStatus === 'failure'} - - {/if} - Push job: - {pushJobId} -
    - {/if}
    {/if} {/if} diff --git a/frontend/src/lib/git-sync.ts b/frontend/src/lib/git-sync.ts new file mode 100644 index 0000000000..3a95ff6dac --- /dev/null +++ b/frontend/src/lib/git-sync.ts @@ -0,0 +1,34 @@ +// Deterministic backend response formats from hub script and CLI +export interface SyncResponse { + success: true + changes: Array<{ + type: 'added' | 'edited' | 'deleted' + path: string + codebase_changed?: boolean + }> + total: number + settingsDiffResult?: { + hasChanges: boolean + diff: Record + local: SettingsObject + backend: SettingsObject + } +} + +export interface SettingsResponse { + success: true + hasChanges: boolean + local: SettingsObject + backend: SettingsObject + diff: Record + repository: string +} + +import type { GitSyncObjectType } from '$lib/gen' + +export interface SettingsObject { + include_path: string[] + exclude_path: string[] + extra_include_path: string[] + include_type: GitSyncObjectType[] +} diff --git a/frontend/src/lib/hubPaths.json b/frontend/src/lib/hubPaths.json index 0f1879acc1..e24b325fa6 100644 --- a/frontend/src/lib/hubPaths.json +++ b/frontend/src/lib/hubPaths.json @@ -8,12 +8,13 @@ "gitSync_6": "hub/11666/sync-script-to-git-repo-windmill", "gitSync_7": "hub/11668/sync-script-to-git-repo-windmill", "gitSync_8": "hub/19673/sync-script-to-git-repo-windmill", - "gitSync": "hub/19738/sync-script-to-git-repo-windmill", + "gitSync_9": "hub/19738/sync-script-to-git-repo-windmill", + "gitSync": "hub/19785/sync-script-to-git-repo-windmill", "gitSyncTest_0": "hub/9073/git-repo-test-read-write-windmill", "gitSyncTest_1": "hub/11499/git-repo-test-read-write-windmill", "gitSyncTest_2": "hub/11667/git-repo-test-read-write-windmill", "gitSyncTest": "hub/11669/git-repo-test-read-write-windmill", - "gitInitRepo": "hub/19740/git-sync%3A-init-repository-windmill", + "gitInitRepo": "hub/19784/git-sync%3A-init-repository-windmill", "slackErrorHandler": "hub/19741/workspace-or-schedule-error-handler-slack", "slackErrorHandler_0": "hub/9079/workspace-or-schedule-error-handler-slack", "slackErrorHandler_1": "hub/9206/workspace-or-schedule-error-handler-slack", diff --git a/frontend/src/lib/services/JobManager.ts b/frontend/src/lib/services/JobManager.ts new file mode 100644 index 0000000000..ebf3159ec9 --- /dev/null +++ b/frontend/src/lib/services/JobManager.ts @@ -0,0 +1,165 @@ +import { JobService } from '$lib/gen' +import { tryEvery } from '$lib/utils' + +export interface JobStatus { + status: 'running' | 'success' | 'failure' + result?: any + error?: string +} + +export interface JobOptions { + onProgress?: (status: JobStatus) => void + timeout?: number + workspace: string + interval?: number + timeoutMessage?: string +} + +interface JobEntry { + controller: AbortController + startTime: number +} + +export class JobManager { + private activeJobs = new Map() + private cleanupInterval: NodeJS.Timeout | null = null + private readonly STALE_TIMEOUT = 300000 // 5 minutes + private readonly CLEANUP_INTERVAL = 60000 // 1 minute + + constructor() { + this.startCleanupTimer() + } + + private startCleanupTimer() { + if (typeof window !== 'undefined') { + this.cleanupInterval = setInterval(() => { + this.cleanup() + }, this.CLEANUP_INTERVAL) + } + } + + private cleanup() { + const now = Date.now() + const staleJobs: string[] = [] + + for (const [jobId, entry] of this.activeJobs.entries()) { + if (now - entry.startTime > this.STALE_TIMEOUT) { + entry.controller.abort() + staleJobs.push(jobId) + } + } + + staleJobs.forEach(jobId => { + this.activeJobs.delete(jobId) + }) + + if (staleJobs.length > 0) { + console.warn(`Cleaned up ${staleJobs.length} stale job controllers`) + } + } + + async runWithProgress( + jobRunner: () => Promise, + options: JobOptions + ): Promise { + const { + onProgress, + timeout = 60000, + workspace, + interval = 500, + timeoutMessage = `Job timed out after ${timeout / 1000}s` + } = options + + const controller = new AbortController() + const jobId = await jobRunner() + + this.activeJobs.set(jobId, { + controller, + startTime: Date.now() + }) + + try { + onProgress?.({ status: 'running' }) + + const result = await tryEvery({ + tryCode: async () => { + if (controller.signal.aborted) { + throw new Error('Job was cancelled') + } + + const jobResult = await JobService.getCompletedJob({ + workspace, + id: jobId + }) + + const success = !!jobResult.success + const status: JobStatus = { + status: success ? 'success' : 'failure', + result: jobResult.result, + error: success ? undefined : (jobResult.result as any)?.error?.message || 'Job failed' + } + + onProgress?.(status) + + if (!success) { + throw new Error(status.error) + } + + return jobResult.result as T + }, + timeoutCode: async () => { + try { + await JobService.cancelQueuedJob({ + workspace, + id: jobId, + requestBody: { reason: timeoutMessage } + }) + } catch (err) { + console.error('Failed to cancel job:', err) + } + + onProgress?.({ status: 'failure', error: timeoutMessage }) + throw new Error(timeoutMessage) + }, + interval, + timeout + }) + + return result as T + } finally { + this.activeJobs.delete(jobId) + } + } + + cancel(jobId: string) { + const entry = this.activeJobs.get(jobId) + if (entry) { + entry.controller.abort() + this.activeJobs.delete(jobId) + } + } + + cancelAll() { + this.activeJobs.forEach(entry => entry.controller.abort()) + this.activeJobs.clear() + } + + isActive(jobId: string): boolean { + return this.activeJobs.has(jobId) + } + + get activeJobCount(): number { + return this.activeJobs.size + } + + destroy() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval) + this.cleanupInterval = null + } + this.cancelAll() + } +} + +// Singleton instance for global usage +export const jobManager = new JobManager() diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index 665d4b1d47..aeb2d1f248 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -4,12 +4,10 @@ import { isCloudHosted } from '$lib/cloud' import CenteredPage from '$lib/components/CenteredPage.svelte' import { Alert, Button, Skeleton, Tab, Tabs } from '$lib/components/common' - import { deepEqual } from 'fast-equals' import DeployToSetting from '$lib/components/DeployToSetting.svelte' import ErrorOrRecoveryHandler from '$lib/components/ErrorOrRecoveryHandler.svelte' import PageHeader from '$lib/components/PageHeader.svelte' - import ResourcePicker from '$lib/components/ResourcePicker.svelte' import ScriptPicker from '$lib/components/ScriptPicker.svelte' import Tooltip from '$lib/components/Tooltip.svelte' @@ -18,7 +16,6 @@ import { OauthService, WorkspaceService, - JobService, ResourceService, SettingService, type AIConfig @@ -32,23 +29,15 @@ isCriticalAlertsUIOpen } from '$lib/stores' import { sendUserToast } from '$lib/toast' - import { emptyString, tryEvery } from '$lib/utils' + import { emptyString } from '$lib/utils' import { - XCircle, RotateCw, - RotateCcw, - CheckCircle2, - Trash, - Plus, - Loader2, - Save, - ExternalLink + Save } from 'lucide-svelte' import PremiumInfo from '$lib/components/settings/PremiumInfo.svelte' import Toggle from '$lib/components/Toggle.svelte' - import { fade } from 'svelte/transition' import ChangeWorkspaceName from '$lib/components/settings/ChangeWorkspaceName.svelte' import ChangeWorkspaceId from '$lib/components/settings/ChangeWorkspaceId.svelte' import ChangeWorkspaceColor from '$lib/components/settings/ChangeWorkspaceColor.svelte' @@ -57,61 +46,13 @@ type S3ResourceSettings } from '$lib/workspace_settings' import { base } from '$lib/base' - import { hubPaths } from '$lib/hub' import Description from '$lib/components/Description.svelte' import ConnectionSection from '$lib/components/ConnectionSection.svelte' import AISettings from '$lib/components/workspaceSettings/AISettings.svelte' import StorageSettings from '$lib/components/workspaceSettings/StorageSettings.svelte' - import InitGitRepoPopover from '$lib/components/InitGitRepoPopover.svelte' - import PullGitRepoPopover from '$lib/components/PullGitRepoPopover.svelte' - import GitSyncFilterSettings from '$lib/components/workspaceSettings/GitSyncFilterSettings.svelte' + import GitSyncSection from '$lib/components/git_sync/GitSyncSection.svelte' import { untrack } from 'svelte' - // Shared defaults for new Git-Sync repositories - const DEFAULT_INCLUDE_PATH = ['f/**'] as const; - const DEFAULT_EXCLUDE_PATH: string[] = []; - const DEFAULT_EXTRA_INCLUDE_PATH: string[] = []; - - type ObjectType = - | 'script' - | 'flow' - | 'app' - | 'folder' - | 'resource' - | 'variable' - | 'secret' - | 'resourcetype' - | 'schedule' - | 'user' - | 'group' - | 'trigger' - | 'settings' - | 'key' - - type GitSyncSettings = { - repositories: GitSyncRepository[] - } - - type GitRepositorySettings = { - include_path: string[] - exclude_path: string[] - extra_include_path: string[] - include_type: ObjectType[] - } - - // Import the generated backend type - import type { GitRepositorySettings as BackendGitRepositorySettings } from '$lib/gen' - - // Frontend repository format extends backend with guaranteed settings and additional UI state - type GitSyncRepository = BackendGitRepositorySettings & { - settings: GitRepositorySettings // Required in frontend after transformation - legacyImported?: boolean - } - - // Workspace-level legacy filter arrays (populated if we imported legacy settings) - let legacyWorkspaceIncludePath = $state([]) - let legacyWorkspaceIncludeType = $state([]) - let slackInitialPath: string = $state('') let slackScriptPath: string = $state('') let teamsInitialPath: string = $state('') @@ -143,16 +84,6 @@ secondaryStorage: undefined }) - let gitSyncSettings = $state({ - repositories: [] - }) - - let gitSyncTestJobs = $state< - { - jobId: string | undefined - status: 'running' | 'success' | 'failure' | undefined - }[] - >([]) let workspaceDefaultAppPath: string | undefined = $state(undefined) let workspaceEncryptionKey: string | undefined = $state(undefined) @@ -172,245 +103,14 @@ ) let usingOpenaiClientCredentialsOauth = $state(false) - let yamlText = $state('') - let initialGitSyncSettings = $state(undefined) + + let loadedSettings = $state(false) - // Reactive trigger to ensure UI updates when repository data changes - let repoReactivityTrigger = $state(0) - const latestGitSyncHubScript = hubPaths.gitSync - // Each repository may have been populated from workspace-level legacy settings. Track on the repo itself. - const anyLegacyImported = $derived(gitSyncSettings.repositories.some((r) => r.legacyImported)) - - // Track changes in repositories - const repoChanges = $derived( - (() => { - // Force reactivity check by accessing the trigger - repoReactivityTrigger; - - return gitSyncSettings.repositories.map((repo, idx) => { - const repoValid = isRepoValid(idx) - - // If there were no initial repos, treat each repo as changed only when valid - if (!initialGitSyncSettings || !initialGitSyncSettings.repositories || initialGitSyncSettings.repositories.length === 0) { - return repoValid - } - - const initial = initialGitSyncSettings.repositories.find( - initialRepo => initialRepo.git_repo_resource_path === repo.git_repo_resource_path - ) - - // If no matching initial repo found, this is a new repo - changed only when valid - if (!initial) return repoValid - - // Handle array ordering for consistent comparison - const settings1 = { - include_path: [...(initial.settings?.include_path ?? [])].sort(), - exclude_path: [...(initial.settings?.exclude_path ?? [])].sort(), - extra_include_path: [...(initial.settings?.extra_include_path ?? [])].sort(), - include_type: [...(initial.settings?.include_type ?? [])].sort() - }; - - const settings2 = { - include_path: [...(repo.settings?.include_path ?? [])].sort(), - exclude_path: [...(repo.settings?.exclude_path ?? [])].sort(), - extra_include_path: [...(repo.settings?.extra_include_path ?? [])].sort(), - include_type: [...(repo.settings?.include_type ?? [])].sort() - }; - - // Compare all properties in a consistent way - const isChanged = !deepEqual( - { - settings: settings1, - use_individual_branch: initial.use_individual_branch, - group_by_folder: initial.group_by_folder, - script_path: initial.script_path, - git_repo_resource_path: initial.git_repo_resource_path, - exclude_types_override: [...(initial.exclude_types_override ?? [])].sort() - }, - { - settings: settings2, - use_individual_branch: repo.use_individual_branch, - group_by_folder: repo.group_by_folder, - script_path: repo.script_path, - git_repo_resource_path: repo.git_repo_resource_path, - exclude_types_override: [...(repo.exclude_types_override ?? [])].sort() - } - ) - - return isChanged && repoValid - }) - })() - ) - - const hasAnyChanges = $derived( - repoChanges.some(Boolean) || - anyLegacyImported || - // Check if the set of valid repos has changed (added/removed repos) - (() => { - if (!initialGitSyncSettings?.repositories) return gitSyncSettings.repositories.filter((_,i)=>isRepoValid(i)).length > 0 - - const initialValidPaths = new Set( - initialGitSyncSettings.repositories - .filter(r => !emptyString(r.git_repo_resource_path)) - .map(r => r.git_repo_resource_path) - ) - const currentValidPaths = new Set( - gitSyncSettings.repositories - .filter((_,i) => isRepoValid(i)) - .map(r => r.git_repo_resource_path) - ) - - // Check if sets are different (repos added or removed) - return initialValidPaths.size !== currentValidPaths.size || - [...initialValidPaths].some(path => !currentValidPaths.has(path)) || - [...currentValidPaths].some(path => !initialValidPaths.has(path)) - })() - ) - - // Helper that tells if a repo card is valid (resource selected and not duplicated) - function isRepoValid(idx: number): boolean { - const repo = gitSyncSettings.repositories[idx] - if (!repo) return false - if (emptyString(repo.git_repo_resource_path)) return false - return !isRepoDuplicate(idx) - } - - // Helper: true if repo shares its resource with an earlier repo - function isRepoDuplicate(idx: number): boolean { - const repo = gitSyncSettings.repositories[idx] - if (!repo || emptyString(repo.git_repo_resource_path)) return false - const firstIdx = gitSyncSettings.repositories.findIndex(r => r.git_repo_resource_path === repo.git_repo_resource_path) - return firstIdx !== idx - } - - function serializeRepo(repo: GitSyncRepository) { - const serialized: any = { - script_path: repo.script_path, - git_repo_resource_path: `$res:${repo.git_repo_resource_path.replace('$res:', '')}`, - use_individual_branch: repo.use_individual_branch, - group_by_folder: repo.group_by_folder, - settings: repo.settings - } - - // exclude_types_override should never be included for migrated repos (only legacy repos have it) - // Migration removes excluded types from include_type and drops exclude_types_override - - return serialized - } - - async function saveRepoSettings(idx: number): Promise { - const currentRepo = gitSyncSettings.repositories[idx] - if (!currentRepo || !isRepoValid(idx)) { - sendUserToast('Cannot save invalid repository (missing or duplicate resource)', true) - return - } - - // If we started with empty settings, we need to save all valid repositories - if (!initialGitSyncSettings || !initialGitSyncSettings.repositories || initialGitSyncSettings.repositories.length === 0) { - // For new repositories starting from empty, save all current repositories with valid resources - const validRepositories = gitSyncSettings.repositories - .filter((_,i)=>isRepoValid(i)) - .map(repo => serializeRepo(repo)) - - await WorkspaceService.editWorkspaceGitSyncConfig({ - workspace: $workspaceStore!, - requestBody: { - git_sync_settings: { repositories: validRepositories } - } - }) - - // Mark all repos migrated and reset legacy arrays - gitSyncSettings.repositories.forEach(r => r.legacyImported = false) - legacyWorkspaceIncludePath = [] - legacyWorkspaceIncludeType = [] - // Update initial settings to reflect what we just saved - initialGitSyncSettings = JSON.parse(JSON.stringify(gitSyncSettings)) - } else { - // Build repositories array: include all repos but serialize differently based on migration status - let repositories: any[] = [] - - // Process all repos that should be in the final payload - for (const repo of gitSyncSettings.repositories) { - if (repo === currentRepo) { - // This is the repo we're saving - migrate if legacy, otherwise serialize normally - if (currentRepo.legacyImported) { - // Migrate legacy repo: remove excluded types from include_type and drop exclude_types_override - const migratedRepo = { - ...currentRepo, - settings: { - ...currentRepo.settings, - include_type: currentRepo.settings.include_type.filter( - type => !currentRepo.exclude_types_override?.includes(type) - ) - }, - exclude_types_override: [], // Clear this for migrated repo - legacyImported: false - } - repositories.push(serializeRepo(migratedRepo)) - // Update the current repo in the UI to reflect migration - Object.assign(currentRepo, migratedRepo) - } else { - repositories.push(serializeRepo(currentRepo)) - } - } else if (repo.legacyImported) { - // This is a legacy repo - serialize without settings field at all - const legacyRepoData: any = { - script_path: repo.script_path, - git_repo_resource_path: `$res:${repo.git_repo_resource_path.replace('$res:', '')}`, - use_individual_branch: repo.use_individual_branch, - group_by_folder: repo.group_by_folder - } - // Include exclude_types_override if it has values - if (repo.exclude_types_override && repo.exclude_types_override.length > 0) { - legacyRepoData.exclude_types_override = repo.exclude_types_override - } - repositories.push(legacyRepoData) - } else { - // This is an already-migrated repo - repositories.push(serializeRepo(repo)) - } - } - - // Mark current repo as migrated - currentRepo.legacyImported = false; - - // Check if there are still legacy repos - const remainingLegacy = gitSyncSettings.repositories.some(r => r.legacyImported) - - const gitSyncPayload: any = { - git_sync_settings: { - repositories, - ...(remainingLegacy && { - include_path: legacyWorkspaceIncludePath, - include_type: legacyWorkspaceIncludeType - }) - } - } - - console.log('Sending payload:', JSON.stringify(gitSyncPayload, null, 2)) - - await WorkspaceService.editWorkspaceGitSyncConfig({ - workspace: $workspaceStore!, - requestBody: gitSyncPayload - }) - - // Update initial settings to reflect current state - initialGitSyncSettings = JSON.parse(JSON.stringify(gitSyncSettings)) - - // if no more legacy repos, clear workspace-level legacy arrays - if (!remainingLegacy) { - legacyWorkspaceIncludePath = [] - legacyWorkspaceIncludeType = [] - } - } - - sendUserToast('Repository settings updated') - } async function editWorkspaceCommand(platform: 'slack' | 'teams'): Promise { if (platform === 'slack') { @@ -466,50 +166,6 @@ } } - async function editWindmillGitSyncSettings(): Promise { - // Filter out repositories with empty resource paths before processing - const validRepos = gitSyncSettings.repositories.filter((_,i)=>isRepoValid(i)) - - let alreadySeenResource: string[] = [] - let repositories = validRepos.map((repo) => { - alreadySeenResource.push(repo.git_repo_resource_path) - - return serializeRepo(repo) - }) - - if (alreadySeenResource.some((res, index) => alreadySeenResource.indexOf(res) !== index)) { - sendUserToast('Same Git resource used more than once', true) - return - } - - if (repositories.length > 0) { - await WorkspaceService.editWorkspaceGitSyncConfig({ - workspace: $workspaceStore!, - requestBody: { - git_sync_settings: { - repositories - } - } - }) - // Update initial settings to reflect what we just saved - initialGitSyncSettings = { - repositories: gitSyncSettings.repositories.filter((_,i)=>isRepoValid(i)) - } - sendUserToast('Workspace Git sync settings updated') - gitSyncSettings.repositories.forEach(r => r.legacyImported = false); - legacyWorkspaceIncludePath = []; - legacyWorkspaceIncludeType = []; - } else { - await WorkspaceService.editWorkspaceGitSyncConfig({ - workspace: $workspaceStore!, - requestBody: { - git_sync_settings: { repositories: [] } - } - }) - initialGitSyncSettings = { repositories: [] } - sendUserToast('Workspace Git sync settings updated (no repositories)') - } - } async function editWorkspaceDefaultApp(appPath: string | undefined): Promise { if (emptyString(appPath)) { @@ -613,55 +269,6 @@ s3ResourceSettings = convertBackendSettingsToFrontendSettings(settings.large_file_storage) - if (settings.git_sync !== undefined && settings.git_sync !== null) { - gitSyncTestJobs = [] - // Derive workspace-level legacy defaults (outside repositories) - const workspaceLegacyIncludePath: string[] = (settings.git_sync as any)?.include_path ?? []; - const workspaceLegacyIncludeTypeRaw: ObjectType[] = (settings.git_sync as any)?.include_type ?? []; - // Note: exclude_types_override is only at repository level, not workspace level - const workspaceLegacyIncludeType: ObjectType[] = [...workspaceLegacyIncludeTypeRaw]; - - legacyWorkspaceIncludePath = [...workspaceLegacyIncludePath]; - legacyWorkspaceIncludeType = [...workspaceLegacyIncludeType]; - - gitSyncSettings.repositories = (settings.git_sync.repositories ?? []).map((repo: BackendGitRepositorySettings) => { - gitSyncTestJobs.push({ - jobId: undefined, - status: undefined - }) - - // Now we have proper nested settings structure from the backend - const defaultTypes: ObjectType[] = workspaceLegacyIncludeType.length > 0 - ? [...workspaceLegacyIncludeType] - : (['script', 'flow', 'app', 'folder'] as ObjectType[]); - - // Check if this is a legacy repo (no nested settings object) - const isRepoLegacy = !repo.settings; - const repoExcludeTypesOverride = repo.exclude_types_override ?? []; - - const repoSettings: GitRepositorySettings = { - include_path: repo.settings?.include_path ?? [...workspaceLegacyIncludePath], - exclude_path: repo.settings?.exclude_path ?? [], - extra_include_path: repo.settings?.extra_include_path ?? [], - include_type: (repo.settings?.include_type ?? [...defaultTypes]) as ObjectType[] - }; - - return { - ...repo, - git_repo_resource_path: repo.git_repo_resource_path.replace('$res:', ''), - collapsed: repo.collapsed ?? false, - settings: repoSettings, - exclude_types_override: repoExcludeTypesOverride, - legacyImported: isRepoLegacy && (legacyWorkspaceIncludePath.length > 0 || legacyWorkspaceIncludeType.length > 0) - } satisfies GitSyncRepository - }) - // Store initial settings - initialGitSyncSettings = JSON.parse(JSON.stringify(gitSyncSettings)) - } else { - gitSyncSettings.repositories = [] - gitSyncTestJobs = [] - initialGitSyncSettings = undefined - } if (settings.deploy_ui != undefined && settings.deploy_ui != null) { deployUiSettings = { include_path: @@ -732,53 +339,9 @@ } } - async function runGitSyncTestJob(settingsIdx: number) { - let gitSyncRepository = gitSyncSettings.repositories[settingsIdx] - if (emptyString(gitSyncRepository.script_path)) { - return - } - let jobId = await JobService.runScriptByPath({ - workspace: $workspaceStore!, - path: hubPaths.gitSyncTest, - requestBody: { - repo_url_resource_path: gitSyncRepository.git_repo_resource_path.replace('$res:', '') - }, - skipPreprocessor: true - }) - gitSyncTestJobs[settingsIdx] = { - jobId: jobId, - status: 'running' - } - gitSyncTestJobs = [...gitSyncTestJobs] - tryEvery({ - tryCode: async () => { - const testResult = await JobService.getCompletedJob({ - workspace: $workspaceStore!, - id: jobId - }) - gitSyncTestJobs[settingsIdx].status = testResult.success ? 'success' : 'failure' - gitSyncTestJobs = [...gitSyncTestJobs] - }, - timeoutCode: async () => { - try { - await JobService.cancelQueuedJob({ - workspace: $workspaceStore!, - id: jobId, - requestBody: { - reason: 'Git sync test job timed out after 5s' - } - }) - } catch (err) { - console.error(err) - } finally { - gitSyncTestJobs[settingsIdx].status = 'failure' - gitSyncTestJobs = [...gitSyncTestJobs] - } - }, - interval: 500, - timeout: 5000 - }) - } + + + async function editCriticalAlertMuteSetting() { await SettingService.workspaceMuteCriticalAlertsUi({ @@ -958,7 +521,7 @@ {:else}
    Deploy to staging/prod from the web UI is only available with an enterprise license
    @@ -1009,7 +572,7 @@ {:else if slack_tabs === 'teams_commands'} {#if !$enterpriseLicense}
    - + Workspace Teams commands is a Windmill EE feature. It enables using your current Slack / Teams connection to run a custom script and send notifications. @@ -1137,7 +700,7 @@ {:else if tab == 'error_handler'} {#if !$enterpriseLicense}
    - + Workspace error handler is a Windmill EE feature. It enables using your current Slack connection or a custom script to send notifications anytime any job would fail. @@ -1254,326 +817,12 @@ {:else if tab == 'windmill_lfs'} {:else if tab == 'git_sync'} -
    -
    -
    Git Sync
    - - Connect the Windmill workspace to a Git repository to automatically commit and push - scripts, flows, and apps to the repository on each deploy. - -
    - - Only new changes matching the filters will trigger a git sync. You still need to initialize - the repo to the desired state first. - -
    - {#if !$enterpriseLicense} -
    - - - Automatically saving scripts to a Git repository on each deploy is a Windmill EE feature. - -
    - {/if} - {#if gitSyncSettings != undefined} -
    - - - -
    -
    - - - - {#if Array.isArray(gitSyncSettings.repositories)} - {#each gitSyncSettings.repositories as repo, idx} -
    - -
    -
    - Repository #{idx + 1} - - {repo.git_repo_resource_path} - -
    -
    - {#if (repoChanges[idx] || repo.legacyImported) && isRepoValid(idx)} - - - {/if} - - -
    -
    - {#if !repo.collapsed} -
    -
    - {#key repo} -
    Resource:
    - - {#if !emptyString(repo.git_repo_resource_path)} - - {/if} - {/key} -
    - - {#if !emptyString(repo.git_repo_resource_path)} -
    - {#if isRepoDuplicate(idx)} - Using the same resource twice is not allowed. - {/if} - {#if gitSyncTestJobs[idx].status !== undefined} - {#if gitSyncTestJobs[idx].status === 'running'} - - {:else if gitSyncTestJobs[idx].status === 'success'} - - {:else} - - {/if} - Git sync resource checked via Windmill job - - {gitSyncTestJobs[idx].jobId} - WARNING: Only read permissions are verified. - {/if} -
    - - {#if repo.legacyImported} - - This repository was initialized from workspace-level legacy Git-Sync settings. Review the filters and press Save to migrate. - - {/if} -
    - {#if gitSyncSettings && repo} - {#if repo.script_path != latestGitSyncHubScript} - - The git sync version for this repository is not latest. Current: {repo.script_path}, latest: - {latestGitSyncHubScript} -
    - -
    -
    - {/if} - { - yamlText = settings.yaml - // Force reactivity update - repoReactivityTrigger = repoReactivityTrigger + 1 - }} - /> -
    - - { - // Direct prop update - much simpler! - repo.settings.include_path = filters.include_path - repo.settings.exclude_path = filters.exclude_path - repo.settings.extra_include_path = filters.extra_include_path - repo.settings.include_type = filters.include_type as ObjectType[] - }} - /> -
    - - - - {/if} -
    - {:else} -
    - Select a git repository resource to configure sync settings. -
    - {/if} -
    - {/if} -
    - {/each} - {/if} - -
    - -
    + {#if $workspaceStore} + {:else} - +
    +
    Loading workspace...
    +
    {/if} {:else if tab == 'default_app'}
    @@ -1590,7 +839,7 @@
    {#if !$enterpriseLicense} - + Default app can only be set on Windmill Enterprise Edition. {/if} @@ -1669,5 +918,7 @@ {/if} + +