From ce80d6b07b63a265cd7cdce0596256eb71f090e0 Mon Sep 17 00:00:00 2001 From: pyranota <92104930+pyranota@users.noreply.github.com> Date: Thu, 17 Oct 2024 15:14:48 +0000 Subject: [PATCH] Multiplayer deploy collision warning (#4533) * feat: Multiplayer deploy collision warning Show warning with diff viewer if 2+ users try to deploy flow/script/app at the same time In other words, if user A and user B started editing same flow/script/app and user A deployed it, user B will get warning if they try to deploy as well. * Add warning for scripts * Add `deployedBy` to Apps * Format * Fix advanced deployment on scripts * Write comments and cleanup * feat(frontend): unify all triggers UX and simplify flow settings (#4259) * feat(frontend): added list of triggers in the flow graph * feat(frontend): added list of triggers in the flow graph * feat(frontend): clean up * feat(frontend): improve UX * feat(frontend): triggers * feat(frontend): triggers * feat(frontend): done * feat(frontend): fix trigger when position when a preprocessor is presetn * Glm/rework flow settings v2 (#4497) * fat(frontend): simplify flow settings menu * improve scroll * changing mute toggle * Add advanced settings badge * Add nord theme colors * Add bage for advanced options * fix minor issue * fix minor issue * Add triggers menu to flow settings * Add quick trigger access * remove triggers in flow settings * fix minor issue * Move triggers settings to flow right panel * polishing * fix unset store * remove save up to for triggers * fix padding * reset default tag color * remove custom select component * revert path change * revert section modif * Revert unused feature --------- Co-authored-by: Guilhem * Connect top bar cron to schedules settings * Turn copilot into node * fix copilot placement * remove useless import * fix center copilot * fix binding * remove copilot on top of preprocessor * render copilot node on condition * quickfix * remove copilot node * fix minor issues * fix route count update * fix schedule sync * harmonize colors * fix alignment and add edges * recenter node summary * fix schedules sync * Add id title * all * all * all * iteration * all * all * done * fix * more fixes --------- Co-authored-by: Guilhem Co-authored-by: Guilhem Co-authored-by: Ruben Fiszel Co-authored-by: Ruben Fiszel * Update ScriptBuilder.svelte * Remove `onMount` for flows * Use version instead of last_updated_at in flows * Use only versions for apps * Fetch latest data in Diffs in Apps * Optimize with (script/flow/app)GetLatestVersion Create several new endpoints, that returns just latest version without rest of the history * Sync Diffs with deployed * Improve Diff's data * Use `getFlowLatestVersion` --------- Co-authored-by: Faton Ramadani Co-authored-by: Guilhem Co-authored-by: Guilhem Co-authored-by: Ruben Fiszel Co-authored-by: Ruben Fiszel --- backend/windmill-api/openapi.yaml | 51 ++++++++++ backend/windmill-api/src/apps.rs | 25 +++++ backend/windmill-api/src/flows.rs | 25 +++++ backend/windmill-api/src/scripts.rs | 26 +++++ .../src/lib/components/FlowBuilder.svelte | 90 ++++++++++++++++-- .../src/lib/components/ScriptBuilder.svelte | 92 ++++++++++++++++-- .../apps/editor/AppEditorHeader.svelte | 94 ++++++++++++++++--- .../DeployOverrideConfirmationModal.svelte | 53 +++++++++++ .../flows/edit/[...path]/+page.svelte | 16 ++++ 9 files changed, 445 insertions(+), 27 deletions(-) create mode 100644 frontend/src/lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 29306fa38c..855cb787fc 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -4092,6 +4092,23 @@ paths: items: $ref: "#/components/schemas/ScriptHistory" + /w/{workspace}/scripts/get_latest_version/{path}: + get: + summary: get scripts's latest version (hash) + operationId: getScriptLatestVersion + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/ScriptPath" + tags: + - script + responses: + "200": + description: Script version/hash + content: + application/json: + schema: + $ref: "#/components/schemas/ScriptHistory" + /w/{workspace}/scripts/history_update/h/{hash}/p/{path}: post: summary: update history of a script @@ -4580,6 +4597,23 @@ paths: items: $ref: "#/components/schemas/FlowVersion" + /w/{workspace}/flows/get_latest_version/{path}: + get: + summary: get flow's latest version + operationId: getFlowLatestVersion + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/ScriptPath" + tags: + - flow + responses: + "200": + description: Flow version + content: + application/json: + schema: + $ref: "#/components/schemas/FlowVersion" + /w/{workspace}/flows/get/v/{version}/p/{path}: get: summary: get flow version @@ -5134,6 +5168,23 @@ paths: items: $ref: "#/components/schemas/AppHistory" + /w/{workspace}/apps/get_latest_version/{path}: + get: + summary: get apps's latest version + operationId: getAppLatestVersion + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/ScriptPath" + tags: + - app + responses: + "200": + description: App version + content: + application/json: + schema: + $ref: "#/components/schemas/AppHistory" + /w/{workspace}/apps/history_update/a/{id}/v/{version}: post: summary: update app history diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 7bed72c50e..0c72d12b50 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -62,6 +62,7 @@ pub fn workspaced_service() -> Router { .route("/delete/*path", delete(delete_app)) .route("/create", post(create_app)) .route("/history/p/*path", get(get_app_history)) + .route("/get_latest_version/*path", get(get_latest_version)) .route("/history_update/a/:id/v/:version", post(update_app_history)) } @@ -427,6 +428,30 @@ async fn get_app_history( return Ok(Json(result)); } +async fn get_latest_version( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, +) -> JsonResult { + let mut tx = user_db.begin(&authed).await?; + let row = sqlx::query!( + "SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg + FROM app a LEFT JOIN app_version av ON a.id = av.app_id LEFT JOIN deployment_metadata dm ON av.id = dm.app_version + WHERE a.workspace_id = $1 AND a.path = $2 + ORDER BY created_at DESC", + w_id, + path.to_path(), + ).fetch_one(&mut *tx).await?; + tx.commit().await?; + + let result = AppHistory { + app_id: row.app_id, + version: row.version_id, + deployment_msg: row.deployment_msg, + }; + return Ok(Json(result)); +} + async fn update_app_history( authed: ApiAuthed, Extension(user_db): Extension, diff --git a/backend/windmill-api/src/flows.rs b/backend/windmill-api/src/flows.rs index de183ae6eb..7e4dd7b4a6 100644 --- a/backend/windmill-api/src/flows.rs +++ b/backend/windmill-api/src/flows.rs @@ -63,6 +63,7 @@ pub fn workspaced_service() -> Router { .route("/exists/*path", get(exists_flow_by_path)) .route("/list_paths", get(list_paths)) .route("/history/p/*path", get(get_flow_history)) + .route("/get_latest_version/*path", get(get_latest_version)) .route( "/history_update/v/:version/p/*path", post(update_flow_history), @@ -538,6 +539,30 @@ async fn get_flow_history( Ok(Json(flows)) } +async fn get_latest_version( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, +) -> JsonResult { + let path = path.to_path(); + let mut tx = user_db.begin(&authed).await?; + + let version = sqlx::query_as!( + FlowVersion, + "SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version + LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version + WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 + ORDER BY flow_version.created_at DESC", + path, + w_id + ) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + + Ok(Json(version)) +} + async fn get_flow_version( authed: ApiAuthed, Extension(user_db): Extension, diff --git a/backend/windmill-api/src/scripts.rs b/backend/windmill-api/src/scripts.rs index 9b91c6b5fb..1560c31b2d 100644 --- a/backend/windmill-api/src/scripts.rs +++ b/backend/windmill-api/src/scripts.rs @@ -152,6 +152,7 @@ pub fn workspaced_service() -> Router { post(toggle_workspace_error_handler), ) .route("/history/p/*path", get(get_script_history)) + .route("/get_latest_version/*path", get(get_latest_version)) .route( "/history_update/h/:hash/p/*path", post(update_script_history), @@ -948,6 +949,31 @@ async fn get_script_history( return Ok(Json(result)); } +async fn get_latest_version( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, +) -> JsonResult { + let mut tx = user_db.begin(&authed).await?; + let row = sqlx::query!( + "SELECT s.hash as hash, dm.deployment_msg as deployment_msg + FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash + WHERE s.workspace_id = $1 AND s.path = $2 + ORDER by created_at DESC", + w_id, + path.to_path(), + ) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + + let result = ScriptHistory { + script_hash: ScriptHash(row.hash), + deployment_msg: row.deployment_msg, // + }; + return Ok(Json(result)); +} + async fn update_script_history( authed: ApiAuthed, Extension(user_db): Extension, diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 5cf0335cb3..fd0b9078d4 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -25,10 +25,12 @@ encodeState, formatCron, orderedJsonStringify, - sleep + sleep, + type Value } from '$lib/utils' import { sendUserToast } from '$lib/toast' import { Drawer } from '$lib/components/common' + import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte' import { setContext, tick, type ComponentType } from 'svelte' import { writable, type Writable } from 'svelte/store' @@ -102,9 +104,29 @@ export let disableAi: boolean = false export let disabledFlowInputs = false export let savedPrimarySchedule: ScheduleTrigger | undefined = undefined + export let version: number | undefined = undefined + + // Used by multiplayer deploy collision warning + let deployedValue: Value | undefined = undefined // Value to diff against + let deployedBy: string | undefined = undefined // Author + let confirmCallback: () => void = () => {} // What happens when user clicks `override` in warning + let open: boolean = false // Is confirmation modal open $: setContext('customUi', customUi) + let onLatest = true + async function compareVersions() { + if (version === undefined) { + return + } + const flowVersion = await FlowService.getFlowLatestVersion({ + workspace: $workspaceStore!, + path: $pathStore + }) + + onLatest = version === flowVersion.id + } + const dispatch = createEventDispatcher() const primaryScheduleStore = writable(savedPrimarySchedule) @@ -253,6 +275,46 @@ ) } + async function handleSaveFlow(deploymentMsg?: string) { + + await compareVersions(); + if (onLatest) { + // Handle directly + await saveFlow(deploymentMsg) + } else { + // We need it for diff + await syncWithDeployed() + + // Handle through confirmation modal + confirmCallback = async () => { + await saveFlow(deploymentMsg) + } + // Open confirmation modal + open = true + } + } + async function syncWithDeployed(){ + const flow = await FlowService.getFlowByPath({ + workspace: $workspaceStore!, + path: $pathStore, + withStarredInfo: true + }) + deployedValue = { + ...flow, + starred: undefined, + id: undefined, + edited_at: undefined, + edited_by: undefined, + workspace_id: undefined, + archived: undefined, + same_worker: undefined, + visible_to_runner_only: undefined, + ws_error_handler_muted: undefined, + } + deployedBy = flow.edited_by + } + + async function saveFlow(deploymentMsg?: string): Promise { loadingSave = true try { @@ -1122,6 +1184,15 @@ + + {#key renderCount} {#if !$userStore?.operator} @@ -1286,14 +1357,17 @@ color="light" variant="border" size="xs" - on:click={() => { + on:click={async () => { if (!savedFlow) { return } + + await syncWithDeployed() + diffDrawer?.openDrawer() diffDrawer?.setDiff({ mode: 'normal', - deployed: savedFlow, + deployed: deployedValue ?? savedFlow, draft: savedFlow['draft'], current: { ...$flowStore, path: $pathStore } }) @@ -1335,7 +1409,9 @@ loading={loadingSave} size="xs" startIcon={{ icon: Save }} - on:click={() => saveFlow()} + on:click={async () => { + await handleSaveFlow() + }} dropdownItems={!newFlow ? dropdownItems : undefined} > Deploy @@ -1346,16 +1422,16 @@ type="text" placeholder="Deployment message" bind:value={deploymentMsg} - on:keydown={(e) => { + on:keydown={async (e) => { if (e.key === 'Enter') { - saveFlow(deploymentMsg) + await handleSaveFlow(deploymentMsg) } }} bind:this={msgInput} /> + {/if} + + diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index 56125d9345..9409ecca89 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -15,6 +15,7 @@ import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' import type { ScheduleTrigger } from '$lib/components/triggers' + let version: undefined | number = undefined; let nodraft = $page.url.searchParams.get('nodraft') const initialState = nodraft ? undefined : localStorage.getItem(`flow-${$page.params.path}`) let stateLoadedFromUrl = initialState != undefined ? decodeState(initialState) : undefined @@ -65,6 +66,13 @@ let flow: Flow let statePath = stateLoadedFromUrl?.path if (stateLoadedFromUrl != undefined && statePath == $page.params.path) { + // Currently there is no way to get version of flow with flow. + // So we have to request it here + version = (await FlowService.getFlowLatestVersion({ + workspace: $workspaceStore!, + path: statePath + })).id; + savedFlow = await FlowService.getFlowByPathWithDraft({ workspace: $workspaceStore!, path: statePath @@ -103,6 +111,13 @@ ]) } } else { + // Currently there is no way to get version of flow with flow. + // So we have to request it here + version = (await FlowService.getFlowLatestVersion({ + workspace: $workspaceStore!, + path: $page.params.path + })).id; + const flowWithDraft = await FlowService.getFlowByPathWithDraft({ workspace: $workspaceStore!, path: $page.params.path @@ -230,6 +245,7 @@ bind:savedFlow {diffDrawer} {savedPrimarySchedule} + bind:version >