diff --git a/backend/windmill-common/src/flows.rs b/backend/windmill-common/src/flows.rs index ab4bdee7b6..a0923ccabf 100644 --- a/backend/windmill-common/src/flows.rs +++ b/backend/windmill-common/src/flows.rs @@ -8,6 +8,8 @@ pub use windmill_types::flows::*; +use anyhow::Context; +use serde::Deserialize; use serde::Serialize; use sqlx::types::Json; use sqlx::types::JsonRawValue; @@ -15,10 +17,89 @@ use sqlx::types::JsonRawValue; use crate::{ cache::{self, FlowExtras}, db::DB, - error::Error, + error::{to_anyhow, Error}, + utils::{http_get_from_hub, StripPath}, worker::{to_raw_value, Connection}, + DEFAULT_HUB_BASE_URL, HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION, }; +#[derive(Deserialize)] +pub struct HubFlow { + pub value: FlowValue, +} + +#[derive(Deserialize)] +struct HubFlowResponse { + flow: HubFlow, +} + +fn extract_hub_flow_id_from_path(path: &str) -> Result { + let hub_flow_path = path.strip_prefix("hub/flows/").ok_or_else(|| { + Error::BadRequest(format!( + "expected hub flow path to start with hub/flows/ (got {path})" + )) + })?; + + let flow_id = hub_flow_path + .split('/') + .next() + .filter(|segment| !segment.is_empty()) + .ok_or_else(|| { + Error::BadRequest(format!( + "expected hub flow path to include a numeric id after hub/flows/ (got {path})" + )) + })?; + + let flow_id = flow_id.parse::().map_err(|_| { + Error::BadRequest(format!( + "expected hub flow path to include a numeric id after hub/flows/ (got {path})" + )) + })?; + + if flow_id <= 0 { + return Err(Error::BadRequest(format!( + "expected hub flow path to include a positive numeric id after hub/flows/ (got {path})" + ))); + } + + Ok(flow_id) +} + +pub async fn get_full_hub_flow_by_path( + path: StripPath, + http_client: &reqwest::Client, + db: Option<&DB>, +) -> crate::error::Result { + let path = path.to_path(); + let flow_id = extract_hub_flow_id_from_path(&path)?; + let hub_base_url = HUB_BASE_URL.read().await.clone(); + let hub_url = format!("{hub_base_url}/flows/{flow_id}/json"); + + let response = match http_get_from_hub(http_client, &hub_url, false, None, db) + .await? + .error_for_status() + .map_err(to_anyhow) + { + Ok(response) => response, + Err(_) if hub_base_url != DEFAULT_HUB_BASE_URL && flow_id < PRIVATE_HUB_MIN_VERSION => + { + tracing::info!("Not found on private hub, fallback to default hub for hub flow {path}"); + let fallback_url = format!("{DEFAULT_HUB_BASE_URL}/flows/{flow_id}/json"); + http_get_from_hub(http_client, &fallback_url, false, None, db) + .await? + .error_for_status() + .map_err(to_anyhow)? + } + Err(err) => return Err(err.into()), + }; + + Ok(response + .json::() + .await + .context(format!("Decoding hub response for flow at path {path}"))? + .flow) +} + /// Serialize-only wrapper that combines resolved FlowValue with display-only extras. /// flatten + RawValue is fine for serialization (only deserialization breaks). #[derive(Serialize)] @@ -228,4 +309,36 @@ mod tests { assert!(!output.contains("notes")); assert!(!output.contains("groups")); } + + #[test] + fn extract_hub_flow_id_accepts_id_only_paths() { + assert_eq!(extract_hub_flow_id_from_path("hub/flows/76").unwrap(), 76); + } + + #[test] + fn extract_hub_flow_id_accepts_id_and_slug_paths() { + assert_eq!( + extract_hub_flow_id_from_path("hub/flows/76/send-message-to-company-ai-assistant") + .unwrap(), + 76 + ); + } + + #[test] + fn extract_hub_flow_id_rejects_non_numeric_ids() { + let err = extract_hub_flow_id_from_path("hub/flows/send_message").unwrap_err(); + assert!(matches!(err, Error::BadRequest(_))); + } + + #[test] + fn extract_hub_flow_id_rejects_missing_ids() { + let err = extract_hub_flow_id_from_path("hub/flows/").unwrap_err(); + assert!(matches!(err, Error::BadRequest(_))); + } + + #[test] + fn extract_hub_flow_id_rejects_zero_ids() { + let err = extract_hub_flow_id_from_path("hub/flows/0").unwrap_err(); + assert!(matches!(err, Error::BadRequest(_))); + } } diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index 583914c573..d5521288bd 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -14,6 +14,7 @@ use crate::{ client::AuthedClient, db::{AuthedRef, UserDbWithAuthed, DB}, error::{self, to_anyhow, Error}, + flows::get_full_hub_flow_by_path, get_latest_deployed_hash_for_path, get_latest_flow_version_info_for_path, scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang}, users::username_to_permissioned_as, @@ -154,15 +155,31 @@ pub async fn get_payload_tag_from_prefixed_path( .await? } else if path.starts_with("flow/") { let path = path.strip_prefix("flow/").unwrap().to_string(); - let FlowVersionInfo { dedicated_worker, tag, version, .. } = - get_latest_flow_version_info_for_path(None, &db, w_id, &path, true).await?; - ( - JobPayload::Flow { path, dedicated_worker, apply_preprocessor: false, version }, - tag, - None, - None, - None, - ) + if path.starts_with("hub/flows/") { + let hub_flow = + get_full_hub_flow_by_path(StripPath(path.clone()), &HTTP_CLIENT, Some(db)).await?; + ( + JobPayload::RawFlow { + value: hub_flow.value, + path: Some(path), + restarted_from: None, + }, + None, + None, + None, + None, + ) + } else { + let FlowVersionInfo { dedicated_worker, tag, version, .. } = + get_latest_flow_version_info_for_path(None, &db, w_id, &path, true).await?; + ( + JobPayload::Flow { path, dedicated_worker, apply_preprocessor: false, version }, + tag, + None, + None, + None, + ) + } } else { return Err(Error::BadRequest(format!( "path must start with script/ or flow/ (got {})", diff --git a/frontend/src/lib/components/FlowGraphViewer.svelte b/frontend/src/lib/components/FlowGraphViewer.svelte index 62e5bdac8e..7e4fceeda7 100644 --- a/frontend/src/lib/components/FlowGraphViewer.svelte +++ b/frontend/src/lib/components/FlowGraphViewer.svelte @@ -1,7 +1,10 @@ -
+
{#if !noGraph}
@@ -61,7 +80,7 @@ cache={flow.value.cache_ttl !== undefined} path={flow?.path} {download} - {minHeight} + minHeight={fillAvailableHeight ? Math.max(minHeight, availableHeight) : minHeight} {workspace} modules={flow?.value?.modules} failureModule={flow?.value?.failure_module} @@ -88,7 +107,9 @@ {#if !noSide && !(hideDefaultInputs && stepDetail == undefined)} - {#if stepDetail.value.path.startsWith('hub/')} -
-

Code

- -
- {/if} +
+

Code

+ +
{:else if stepDetail.value.type == 'rawscript'}

Step inputs

@@ -218,27 +211,16 @@
{/if} - {#if stepDetail.value.path.startsWith('hub/')} -
-
-

Code

- -
- -
- {:else} - - {/if} +
+

Code

+ +
+ {:else if stepDetail.value.type == 'aiagent'}

Step inputs

diff --git a/frontend/src/lib/components/FlowMetadata.svelte b/frontend/src/lib/components/FlowMetadata.svelte index 3d4be84545..022fa874a2 100644 --- a/frontend/src/lib/components/FlowMetadata.svelte +++ b/frontend/src/lib/components/FlowMetadata.svelte @@ -2,6 +2,7 @@ import { type Job } from '$lib/gen' import { base } from '$lib/base' import JobStatus from '$lib/components/JobStatus.svelte' + import { flowPathToHref } from '$lib/scripts' import { displayDate, truncateRev } from '$lib/utils' import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte' import TimeAgo from './TimeAgo.svelte' @@ -94,7 +95,9 @@ {#if (job && job.job_kind == 'flow') || job?.job_kind == 'script'} {@const stem = `${job?.job_kind}s`} {@const isScript = job?.job_kind === 'script'} - {@const viewHref = `${base}/${stem}/get/${isScript ? job?.script_hash : job?.script_path}`} + {@const viewHref = isScript + ? `${base}/${stem}/get/${job?.script_hash}` + : flowPathToHref(job?.script_path ?? '')}
{#if isScript} diff --git a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptRunnableByPath.svelte b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptRunnableByPath.svelte index 5d6ddf7b93..6c991ea52a 100644 --- a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptRunnableByPath.svelte +++ b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptRunnableByPath.svelte @@ -4,9 +4,11 @@ const bubble = createBubbler() import { Button, Drawer, DrawerContent } from '$lib/components/common' import { base } from '$lib/base' + import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte' + import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte' import FlowModuleScript from '$lib/components/flows/content/FlowModuleScript.svelte' import FlowPathViewer from '$lib/components/flows/content/FlowPathViewer.svelte' - import { emptySchema, sendUserToast } from '$lib/utils' + import { emptySchema, getHubFlowIdFromPath, isHubFlowPath, sendUserToast } from '$lib/utils' import { getContext, tick, untrack } from 'svelte' import type { ConnectedAppInput, @@ -31,7 +33,8 @@ import Popover from '$lib/components/meltComponents/Popover.svelte' import ScriptEditorDrawer from '$lib/components/flows/content/ScriptEditorDrawer.svelte' import FlowEditorDrawer from '$lib/components/flows/content/FlowEditorDrawer.svelte' - import { ScriptService } from '$lib/gen' + import { FlowService, ScriptService, type OpenFlow } from '$lib/gen' + import { replaceScriptPlaceholderWithItsValues } from '$lib/hub' interface Props { runnable: RunnableByPath @@ -43,6 +46,7 @@ isLoading?: boolean onRun?: any onCancel?: any + hubFlowPreview?: OpenFlow | undefined } let { @@ -52,14 +56,17 @@ rawApps = false, isLoading = false, onRun = async () => {}, - onCancel = async () => {} + onCancel = async () => {}, + hubFlowPreview = $bindable(undefined) }: Props = $props() const viewerContext = getContext('AppViewerContext') let drawerFlowViewer: Drawer | undefined = $state(undefined) let flowPath: string = $state('') + let drawerShowsHubFlow = $state(false) let notFound = $state(false) + let hubFlowId = $derived(getHubFlowIdFromPath(runnable.path)) // Key to force re-mounting of viewer components (bypasses FlowModuleScript cache) let refreshKey = $state(0) @@ -70,6 +77,7 @@ const dispatch = createEventDispatcher() async function refreshScript(runnable: RunnableByPath) { + hubFlowPreview = undefined try { let { schema } = await getScriptByPath(runnable.path) if (!deepEqual(runnable.schema, schema)) { @@ -86,7 +94,39 @@ } async function refreshFlow(runnable: RunnableByPath) { + hubFlowPreview = undefined try { + const hubFlowId = getHubFlowIdFromPath(runnable.path) + if (hubFlowId !== undefined) { + const hub = await FlowService.getHubFlowById({ id: hubFlowId }) + const flow = hub.flow ? structuredClone(hub.flow) : undefined + if (flow?.value.preprocessor_module?.value.type === 'rawscript') { + flow.value.preprocessor_module.value.content = replaceScriptPlaceholderWithItsValues( + String(hubFlowId), + flow.value.preprocessor_module.value.content + ) + } + + if (!flow) { + notFound = true + return + } + + hubFlowPreview = flow + const schema = + flow.schema && typeof flow.schema === 'object' && Object.keys(flow.schema).length > 0 + ? (flow.schema as any) + : emptySchema() + if (!deepEqual(runnable.schema, schema)) { + runnable.schema = schema + if (!runnable.schema.order) { + runnable.schema.order = Object.keys(runnable.schema.properties ?? {}) + } + fields = computeFields(schema, false, fields ?? {}) + } + return + } + const { schema } = (await loadSchema($workspaceStore ?? '', runnable.path, 'flow')) ?? emptySchema() if (!deepEqual(runnable.schema, schema)) { @@ -158,6 +198,8 @@ refreshScript(runnable) } else if (runnable.runType == 'flow') { refreshFlow(runnable) + } else { + hubFlowPreview = undefined } lastRunnable = runnable } @@ -170,8 +212,34 @@ - - + { + flowPath = '' + drawerShowsHubFlow = false + drawerFlowViewer?.closeDrawer() + }} + > + {#if drawerShowsHubFlow} +
+ {#if hubFlowPreview} + + {:else if notFound} +
Hub flow not found at {flowPath}
+ {:else} +
+ +
+ {/if} +
+ {:else if flowPath} + + {/if}
@@ -210,7 +278,7 @@ size="xs" startIcon={{ icon: RefreshCw }} on:click={async () => { - sendUserToast('Getting latest script version at that path') + sendUserToast('Getting latest runnable version at that path') // Increment refreshKey to force re-mounting of viewer components (bypasses cache) refreshKey++ lastRunnable = undefined @@ -238,31 +306,45 @@ startIcon={{ icon: Eye }} on:click={() => { flowPath = runnable.path + drawerShowsHubFlow = isHubFlowPath(runnable.path) drawerFlowViewer?.openDrawer() }} > Expand - - + {#if hubFlowId} + + {:else} + + + {/if} {:else} + Cache + {/snippet} {#snippet content()} - Since this is a reference to a workspace {runnable.runType}, set the cache in the {runnable.runType} - settings directly by editing it. The cache will be shared by any app or flow that uses this - {runnable.runType}. + {#if runnable.runType == 'flow' && isHubFlowPath(runnable.path)} + Since this is a reference to a hub flow, cache settings are managed from the flow after + you fork it into your workspace. + {:else} + Since this is a reference to a workspace {runnable.runType}, set the cache in the + {runnable.runType} settings directly by editing it. The cache will be shared by any app or + flow that uses this {runnable.runType}. + {/if} {/snippet} @@ -325,18 +414,37 @@ class="!text-xs !rounded-xs" />
-
+
{#key `${viewerContext?.stateId ? get(viewerContext.stateId) : 0}-${refreshKey}`} {#if notFound} -
{runnable.runType} not found at {runnable.path} in workspace {$workspaceStore}
+
+ {#if runnable.runType == 'flow' && isHubFlowPath(runnable.path)} + Hub flow not found at {runnable.path} + {:else} + {runnable.runType} not found at {runnable.path} in workspace {$workspaceStore} + {/if} +
{:else if runnable.runType == 'script' || runnable.runType == 'hubscript'}
{:else if runnable.runType == 'flow'} - + {#if isHubFlowPath(runnable.path)} + {#if hubFlowPreview} +
+ +
+ {:else} + + {/if} + {:else} + + {/if} {:else} Unrecognized runType {runnable.runType} {/if} diff --git a/frontend/src/lib/components/flows/content/FlowModuleScript.svelte b/frontend/src/lib/components/flows/content/FlowModuleScript.svelte index 7b63910010..cc3a75fde9 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleScript.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleScript.svelte @@ -90,7 +90,9 @@ async function loadCode(path: string, hash: string | undefined) { try { notFound = false - const script = hash + const script = path.startsWith('hub/') + ? await getScriptByPath(path!) + : hash ? await ScriptService.getScriptByHash({ workspace: $workspaceStore!, hash }) : await getScriptByPath(path!) code = script.content diff --git a/frontend/src/lib/components/flows/content/FlowPathViewer.svelte b/frontend/src/lib/components/flows/content/FlowPathViewer.svelte index c1c4b5c9de..e51535782f 100644 --- a/frontend/src/lib/components/flows/content/FlowPathViewer.svelte +++ b/frontend/src/lib/components/flows/content/FlowPathViewer.svelte @@ -13,9 +13,10 @@ interface Props { path: string; noSide?: boolean; + fillAvailableHeight?: boolean; } - let { path, noSide = false }: Props = $props(); + let { path, noSide = false, fillAvailableHeight = false }: Props = $props(); let flow: Flow | undefined = $state(undefined) @@ -41,7 +42,7 @@
{#if flow} - + {:else} {/if} diff --git a/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte b/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte index f942e1a719..f89c504343 100644 --- a/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte @@ -20,13 +20,15 @@ import SchemaForm from '../SchemaForm.svelte' import RunnableJobPanelInner from '../apps/editor/RunnableJobPanelInner.svelte' import JobLoader from '../JobLoader.svelte' - import type { Job, ScriptLang } from '$lib/gen' + import type { Job, OpenFlow, ScriptLang } from '$lib/gen' import { slide } from 'svelte/transition' import { DebugToolbar, DebugPanel, debugState } from '$lib/components/debug' import LogViewer from '$lib/components/LogViewer.svelte' import DisplayResult from '$lib/components/DisplayResult.svelte' import RunButton from '$lib/components/RunButton.svelte' import { userStore, workspaceStore } from '$lib/stores' + import { isHubFlowPath } from '$lib/utils' + import { sendUserToast } from '$lib/toast' type RunnableWithInlineScript = RunnableWithFields & { inlineScript?: InlineScript & { language: ScriptLang } @@ -70,6 +72,7 @@ let selectedTab = $state('test') let args = $state({}) + let hubFlowPreview: OpenFlow | undefined = $state(undefined) function getSchema(runnable: RunnableWithFields) { if (isRunnableByPath(runnable)) { @@ -162,7 +165,15 @@ } else if (isRunnableByPath(runnable)) { if (jobLoader && isRunnableByPath(runnable)) { if (runnable.runType == 'flow') { - await jobLoader.runFlowByPath(runnable.path, args) + if (isHubFlowPath(runnable.path)) { + if (!hubFlowPreview) { + sendUserToast('Hub flow preview is still loading', true) + return + } + await jobLoader.runFlowPreview(args, hubFlowPreview, undefined, runnable.path) + } else { + await jobLoader.runFlowByPath(runnable.path, args) + } } else if (runnable.runType == 'script' || runnable.runType == 'hubscript') { await jobLoader.runScriptByPath(runnable.path, args) } @@ -203,6 +214,7 @@ rawApps bind:runnable bind:fields={runnable.fields} + bind:hubFlowPreview on:fork={(e) => fork(e.detail)} on:delete {id} diff --git a/frontend/src/lib/components/runs/JobDetailFieldConfig.ts b/frontend/src/lib/components/runs/JobDetailFieldConfig.ts index 23a7df12cd..50edf9e7cf 100644 --- a/frontend/src/lib/components/runs/JobDetailFieldConfig.ts +++ b/frontend/src/lib/components/runs/JobDetailFieldConfig.ts @@ -1,6 +1,7 @@ import type { Job } from '$lib/gen' import { triggerIconMap } from '$lib/components/triggers/utils' import { formatMemory } from '$lib/utils' +import { flowPathToHref } from '$lib/scripts' import { Calendar, Bot } from 'lucide-svelte' import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte' @@ -240,11 +241,12 @@ export const fieldConfigs: Record = { field: 'script_path', label: 'Path', getValue: (job) => job.script_path || null, - getHref: (job, workspaceId) => { + getHref: (job, _workspaceId) => { if (!job.script_path) return null - const stem = job.job_kind === 'script' ? 'scripts' : 'flows' const isScript = job.job_kind === 'script' - return `/${stem}/get/${isScript ? job.script_hash : job.script_path}` + return isScript + ? `/scripts/get/${job.script_hash}` + : flowPathToHref(job.script_path) } }, diff --git a/frontend/src/lib/components/runs/JobDetailHeader.svelte b/frontend/src/lib/components/runs/JobDetailHeader.svelte index 7ca973dd00..b0941ddcad 100644 --- a/frontend/src/lib/components/runs/JobDetailHeader.svelte +++ b/frontend/src/lib/components/runs/JobDetailHeader.svelte @@ -14,6 +14,7 @@ import Button from '$lib/components/common/button/Button.svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' import { getRelevantFields, getTriggerInfo, type FieldConfig } from './JobDetailFieldConfig' + import { flowPathToHref } from '$lib/scripts' import { slide } from 'svelte/transition' import { twMerge } from 'tailwind-merge' @@ -385,10 +386,12 @@
- {#if job.script_path && (job.job_kind === 'script' || job.job_kind === 'flow' || job.job_kind === 'singlestepflow')} + {#if job.script_path && (job.job_kind === 'script' || job.job_kind === 'flow' || job.job_kind === 'singlestepflow' || job.job_kind === 'flowpreview')} {@const stem = job.job_kind === 'script' ? 'scripts' : 'flows'} {@const isScript = job.job_kind === 'script'} - {@const viewHref = `${base}/${stem}/get/${isScript ? job?.script_hash : job?.script_path}`} + {@const viewHref = isScript + ? `${base}/${stem}/get/${job?.script_hash}` + : flowPathToHref(job?.script_path ?? '')} import Tooltip from '$lib/components/meltComponents/Tooltip.svelte' import PreprocessedArgsDisplay from '$lib/components/runs/PreprocessedArgsDisplay.svelte' - import { truncateHash } from '$lib/utils' + import { getJobKindDisplayLabel, truncateHash } from '$lib/utils' import { base } from '$lib/base' import { truncateRev } from '$lib/utils' import { workspaceStore } from '$lib/stores' @@ -46,7 +46,7 @@ {/if} {#if job && 'job_kind' in job}
- Job kind: {job.job_kind} + Job kind: {getJobKindDisplayLabel(job.job_kind, job.script_path)}
{/if} {#if job && job.flow_status && job.job_kind === 'script'} diff --git a/frontend/src/lib/components/runs/RunRow.svelte b/frontend/src/lib/components/runs/RunRow.svelte index 3a14c71111..d4b1ac8403 100644 --- a/frontend/src/lib/components/runs/RunRow.svelte +++ b/frontend/src/lib/components/runs/RunRow.svelte @@ -9,6 +9,7 @@ isScriptPreview, msToReadableTime, isFlowPreview, + getJobKindDisplayLabel, getJobKindIcon } from '$lib/utils' import { Button } from '../common' @@ -155,12 +156,12 @@ {/if}
- {#snippet text()} - - {#if job && job.job_kind} - {job.job_kind} - {/if} - {#if job && job.is_flow_step && job.parent_job} + {#snippet text()} + + {#if job && job.job_kind} + {getJobKindDisplayLabel(job.job_kind, job.script_path)} + {/if} + {#if job && job.is_flow_step && job.parent_job}
Step of flow
{truncateRev(job.parent_job, 10)} diff --git a/frontend/src/lib/scripts.ts b/frontend/src/lib/scripts.ts index e9f2328b71..5ddeaeb148 100644 --- a/frontend/src/lib/scripts.ts +++ b/frontend/src/lib/scripts.ts @@ -2,7 +2,8 @@ import { get } from 'svelte/store' import { base } from '$lib/base' import type { Schema, SupportedLanguage } from './common' import { FlowService, type Script, ScriptService, ScheduleService } from './gen' -import { workspaceStore } from './stores' +import { hubBaseUrlStore, workspaceStore } from './stores' +import { getHubFlowIdFromPath } from './utils' export function scriptLangToEditorLang( lang: @@ -129,6 +130,15 @@ export function scriptPathToHref(path: string, hubBaseUrl: string): string { } } +export function flowPathToHref(path: string, hubBaseUrl: string = get(hubBaseUrlStore)): string { + if (path.startsWith('hub/flows/')) { + const hubFlowId = getHubFlowIdFromPath(path) + return hubFlowId ? `${hubBaseUrl}/flows/${hubFlowId}` : hubBaseUrl + } + + return `${base}/flows/get/${path}?workspace=${get(workspaceStore)}` +} + const scriptLanguagesArray: [SupportedLanguage | 'docker' | 'bunnative', string][] = [ ['bun', 'TypeScript (Bun)'], ['python3', 'Python'], diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 49a9568312..0911fe44e1 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1464,6 +1464,35 @@ export function isFlowPreview(job_kind: Job['job_kind'] | undefined) { return !!job_kind && (job_kind === 'flowpreview' || job_kind === 'flownode') } +export function isHubFlowPath(scriptPath: string | undefined | null) { + return !!scriptPath && scriptPath.startsWith('hub/flows/') +} + +export function getHubFlowIdFromPath(scriptPath: string | undefined | null) { + if (!scriptPath || !isHubFlowPath(scriptPath)) { + return undefined + } + + const hubFlowPath = scriptPath.substring('hub/flows/'.length) + const [idPart] = hubFlowPath.split('/') + const id = Number(idPart) + + return Number.isInteger(id) && id > 0 ? id : undefined +} + +export function getJobKindDisplayLabel( + jobKind: Job['job_kind'] | undefined, + scriptPath: string | undefined | null +) { + if (jobKind === 'script_hub') { + return 'Script from hub' + } + if (isFlowPreview(jobKind) && isHubFlowPath(scriptPath)) { + return 'Flow from hub' + } + return jobKind ?? '' +} + export function isNotFlow(job_kind: Job['job_kind'] | undefined) { return job_kind !== 'flow' && job_kind !== 'singlestepflow' && !isFlowPreview(job_kind) } diff --git a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte index 411576ef41..1b8503163e 100644 --- a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte @@ -153,7 +153,9 @@ } else if (hubId) { const hub = await FlowService.getHubFlowById({ id: Number(hubId) }) delete hub['comments'] - initialPath = `u/${$userStore?.username}/flow_${hubId}` + initialPath = `u/${$userStore?.username + .split('@')[0] + .replace(/[^a-zA-Z0-9_]/g, '')}/flow_${hubId}` Object.assign(flow, hub.flow) if (flow.value.preprocessor_module?.value.type === 'rawscript') { flow.value.preprocessor_module.value.content = replaceScriptPlaceholderWithItsValues( diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index 5f7e426fe9..b1c5f9419e 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -17,6 +17,8 @@ copyToClipboard, emptyString, encodeState, + getHubFlowIdFromPath, + isHubFlowPath, isFlowPreview, isNotFlow, isScriptPreview @@ -295,6 +297,17 @@ function forkPreview() { if (isFlowPreview(job?.job_kind)) { + if (isHubFlowPath(job?.script_path)) { + const hubFlowId = getHubFlowIdFromPath(job?.script_path) + if (hubFlowId === undefined) { + sendUserToast('Could not determine the hub flow to fork', true) + return + } + $initialArgsStore = job?.args + window.open(`/flows/add?hub=${hubFlowId}`) + return + } + const state = { flow: { value: job?.raw_flow }, path: job?.script_path + '_fork', @@ -492,9 +505,10 @@ {#snippet left()}

run/{page.params.run}

{/snippet} - {#snippet right()} - {@const isScript = job?.job_kind === 'script'} - {@const runsHref = `/runs/${job?.script_path}${!isScript ? '?jobKind=flow' : ''}`} + {#snippet right()} + {@const isScript = job?.job_kind === 'script'} + {@const isHubFlowPreview = isFlowPreview(job?.job_kind) && isHubFlowPath(job?.script_path)} + {@const runsHref = `/runs/${job?.script_path}${!isScript ? '?jobKind=flow' : ''}`} {#if job && 'deleted' in job && !job?.deleted && ($superadmin || ($userStore?.is_admin ?? false))}
{/if} - {#if isFlowPreview(job?.job_kind) || isScriptPreview(job?.job_kind)} - - {/if} + {#if isFlowPreview(job?.job_kind) || isScriptPreview(job?.job_kind)} + + {/if} {#if persistentScriptDefinition !== undefined}