feat: support hub flows in raw app runnables (#8627)
* feat: support hub flows in raw app runnables Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: support hub flow previews in app ui Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: move trigger context into flow graph viewer Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use script viewer for hub flow steps Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: stretch raw app flow previews to pane height Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: improve hub flow run links Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: stabilize hub flow preview drawer Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: align hub flow id validation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style: fix runnable panel indentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<i32, Error> {
|
||||
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::<i32>().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<HubFlow> {
|
||||
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::<HubFlowResponse>()
|
||||
.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(_)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {})",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { FlowModule, FlowValue } from '$lib/gen'
|
||||
import type { FlowModule, FlowValue, TriggersCount } from '$lib/gen'
|
||||
import type { TriggerContext } from '$lib/components/triggers'
|
||||
import { Triggers } from '$lib/components/triggers/triggers.svelte'
|
||||
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { createEventDispatcher, hasContext, setContext } from 'svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import FlowGraphViewerStep from './FlowGraphViewerStep.svelte'
|
||||
@@ -27,6 +30,8 @@
|
||||
minHeight?: number
|
||||
noBorder?: boolean
|
||||
hideDefaultInputs?: boolean
|
||||
provideTriggerContext?: boolean
|
||||
fillAvailableHeight?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -40,18 +45,32 @@
|
||||
workspace = $workspaceStore,
|
||||
minHeight = 400,
|
||||
noBorder = false,
|
||||
hideDefaultInputs = false
|
||||
hideDefaultInputs = false,
|
||||
provideTriggerContext = false,
|
||||
fillAvailableHeight = false
|
||||
}: Props = $props()
|
||||
|
||||
let availableHeight = $state(0)
|
||||
|
||||
if (provideTriggerContext && !hasContext('TriggerContext')) {
|
||||
const triggersCount = writable<TriggersCount | undefined>(undefined)
|
||||
setContext<TriggerContext>('TriggerContext', {
|
||||
triggersCount,
|
||||
simplifiedPoll: writable(false),
|
||||
showCaptureHint: writable(undefined),
|
||||
triggersState: new Triggers()
|
||||
})
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-3 w-full h-full">
|
||||
<div bind:clientHeight={availableHeight} class="grid grid-cols-3 w-full h-full min-h-0">
|
||||
{#if !noGraph}
|
||||
<div
|
||||
class="{noSide || (hideDefaultInputs && stepDetail == undefined)
|
||||
? 'col-span-3'
|
||||
: 'sm:col-span-2 col-span-3'} w-full max-h-full"
|
||||
: 'sm:col-span-2 col-span-3'} w-full h-full min-h-0 max-h-full"
|
||||
class:overflow-auto={overflowAuto}
|
||||
class:border={!noBorder}
|
||||
>
|
||||
@@ -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)}
|
||||
<div
|
||||
class={twMerge(
|
||||
'relative w-full h-full min-h-[150px] max-h-[90vh] border-r border-b border-t p-2 pt-0 overflow-auto hidden sm:flex flex-col gap-4',
|
||||
fillAvailableHeight
|
||||
? 'relative w-full h-full min-h-0 border-r border-b border-t p-2 pt-0 overflow-auto hidden sm:flex flex-col gap-4'
|
||||
: 'relative w-full h-full min-h-[150px] max-h-[90vh] border-r border-b border-t p-2 pt-0 overflow-auto hidden sm:flex flex-col gap-4',
|
||||
noGraph ? 'border-0 w-max' : ''
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -51,17 +51,10 @@
|
||||
|
||||
<InputTransformsViewer inputTransforms={stepDetail?.value?.input_transforms ?? {}} />
|
||||
</div>
|
||||
{#if stepDetail.value.path.startsWith('hub/')}
|
||||
<div class="mt-6">
|
||||
<h3 class="mb-1 mt-6 text-xs font-semibold text-emphasis">Code</h3>
|
||||
<iframe
|
||||
class="w-full h-full text-sm"
|
||||
title="embedded script from hub"
|
||||
frameborder="0"
|
||||
src="{$hubBaseUrlStore}/embed/script/{stepDetail.value?.path?.substring(4)}"
|
||||
></iframe>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-6">
|
||||
<h3 class="mb-1 mt-6 text-xs font-semibold text-emphasis">Code</h3>
|
||||
<FlowModuleScript path={stepDetail.value.path} hash={jobScriptHash} />
|
||||
</div>
|
||||
{:else if stepDetail.value.type == 'rawscript'}
|
||||
<div class="text-2xs mb-4 mt-2">
|
||||
<h3 class="mb-1 text-xs font-semibold text-emphasis">Step inputs</h3>
|
||||
@@ -218,27 +211,16 @@
|
||||
<InputTransformsViewer inputTransforms={stepDetail?.value?.input_transforms ?? {}} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if stepDetail.value.path.startsWith('hub/')}
|
||||
<div class="flex flex-col grow">
|
||||
<div class="mb-1 mt-6 flex justify-between items-center">
|
||||
<h3 class="font-semibold text-xs text-emphasis">Code</h3>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
onClick={codeViewer?.openDrawer}
|
||||
startIcon={{ icon: Expand }}>Expand</Button
|
||||
>
|
||||
</div>
|
||||
<iframe
|
||||
class="w-full grow text-sm h-full"
|
||||
title="embedded script from hub"
|
||||
frameborder="0"
|
||||
src="{$hubBaseUrlStore}/embed/script/{stepDetail.value?.path?.substring(4)}"
|
||||
></iframe>
|
||||
</div>
|
||||
{:else}
|
||||
<FlowModuleScript path={stepDetail.value.path} hash={jobScriptHash} />
|
||||
{/if}
|
||||
<div class="mb-1 mt-6 flex justify-between items-center">
|
||||
<h3 class="font-semibold text-xs text-emphasis">Code</h3>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
onClick={codeViewer?.openDrawer}
|
||||
startIcon={{ icon: Expand }}>Expand</Button
|
||||
>
|
||||
</div>
|
||||
<FlowModuleScript path={stepDetail.value.path} hash={jobScriptHash} />
|
||||
{:else if stepDetail.value.type == 'aiagent'}
|
||||
<div class="text-xs">
|
||||
<h3 class="mb-1 font-semibold mt-2 text-xs text-emphasis">Step inputs</h3>
|
||||
|
||||
@@ -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 ?? '')}
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
{#if isScript}
|
||||
<Code2 size={SMALL_ICON_SIZE} class="min-w-3.5" />
|
||||
|
||||
@@ -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>('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 @@
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawerFlowViewer} size="1200px">
|
||||
<DrawerContent title="Flow {flowPath}" on:close={drawerFlowViewer.closeDrawer}>
|
||||
<FlowPathViewer path={flowPath ?? ''} />
|
||||
<DrawerContent
|
||||
title="Flow {flowPath}"
|
||||
on:close={() => {
|
||||
flowPath = ''
|
||||
drawerShowsHubFlow = false
|
||||
drawerFlowViewer?.closeDrawer()
|
||||
}}
|
||||
>
|
||||
{#if drawerShowsHubFlow}
|
||||
<div class="flex flex-col flex-1 h-full min-h-0 overflow-auto">
|
||||
{#if hubFlowPreview}
|
||||
<FlowGraphViewer
|
||||
triggerNode
|
||||
provideTriggerContext
|
||||
fillAvailableHeight
|
||||
flow={{ ...hubFlowPreview, path: flowPath }}
|
||||
/>
|
||||
{:else if notFound}
|
||||
<div class="p-4 text-red-400">Hub flow not found at {flowPath}</div>
|
||||
{:else}
|
||||
<div class="p-4">
|
||||
<Skeleton layout={[[40]]} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if flowPath}
|
||||
<FlowPathViewer path={flowPath} fillAvailableHeight />
|
||||
{/if}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@@ -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
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
startIcon={{ icon: Pen }}
|
||||
on:click={() => {
|
||||
openFlowEditor(runnable.path)
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
startIcon={{ icon: Eye }}
|
||||
endIcon={{ icon: ExternalLink }}
|
||||
target="_blank"
|
||||
href="{base}/flows/get/{runnable.path}?workspace={$workspaceStore}"
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
{#if hubFlowId}
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
startIcon={{ icon: GitFork }}
|
||||
endIcon={{ icon: ExternalLink }}
|
||||
target="_blank"
|
||||
href="{base}/flows/add?hub={hubFlowId}"
|
||||
>
|
||||
Fork
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
startIcon={{ icon: Pen }}
|
||||
on:click={() => {
|
||||
openFlowEditor(runnable.path)
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
startIcon={{ icon: Eye }}
|
||||
endIcon={{ icon: ExternalLink }}
|
||||
target="_blank"
|
||||
href="{base}/flows/get/{runnable.path}?workspace={$workspaceStore}"
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
{/if}
|
||||
{:else}
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -308,13 +390,20 @@
|
||||
nonCaptureEvent={true}
|
||||
btnClasses={'bg-surface text-primay hover:bg-hover'}
|
||||
variant="default"
|
||||
size="xs">Cache</Button
|
||||
size="xs"
|
||||
>
|
||||
Cache
|
||||
</Button>
|
||||
{/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}
|
||||
</Popover>
|
||||
|
||||
@@ -325,18 +414,37 @@
|
||||
class="!text-xs !rounded-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full grow overflow-y-auto">
|
||||
<div class="w-full grow min-h-0 overflow-y-auto">
|
||||
{#key `${viewerContext?.stateId ? get(viewerContext.stateId) : 0}-${refreshKey}`}
|
||||
{#if notFound}
|
||||
<div class="text-red-400"
|
||||
>{runnable.runType} not found at {runnable.path} in workspace {$workspaceStore}</div
|
||||
>
|
||||
<div class="text-red-400">
|
||||
{#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}
|
||||
</div>
|
||||
{:else if runnable.runType == 'script' || runnable.runType == 'hubscript'}
|
||||
<div class="border">
|
||||
<FlowModuleScript path={runnable.path} />
|
||||
</div>
|
||||
{:else if runnable.runType == 'flow'}
|
||||
<FlowPathViewer path={runnable.path} />
|
||||
{#if isHubFlowPath(runnable.path)}
|
||||
{#if hubFlowPreview}
|
||||
<div class="flex flex-col flex-1 h-full min-h-0 overflow-auto">
|
||||
<FlowGraphViewer
|
||||
triggerNode
|
||||
provideTriggerContext
|
||||
fillAvailableHeight
|
||||
flow={{ ...hubFlowPreview, path: runnable.path }}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<Skeleton layout={[[40]]} />
|
||||
{/if}
|
||||
{:else}
|
||||
<FlowPathViewer path={runnable.path} fillAvailableHeight />
|
||||
{/if}
|
||||
{:else}
|
||||
Unrecognized runType {runnable.runType}
|
||||
{/if}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
<div class="flex flex-col flex-1 h-full overflow-auto">
|
||||
{#if flow}
|
||||
<FlowGraphViewer triggerNode={true} {noSide} {flow} />
|
||||
<FlowGraphViewer triggerNode={true} {noSide} {flow} {fillAvailableHeight} />
|
||||
{:else}
|
||||
<Skeleton layout={[[40]]} />
|
||||
{/if}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<JobField, FieldConfig> = {
|
||||
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)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
<div class="flex flex-col gap-1 flex-1 min-w-0">
|
||||
<!-- Title row -->
|
||||
<div class="min-w-0 grow">
|
||||
{#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 ?? '')}
|
||||
<a
|
||||
href={viewHref}
|
||||
class="text-emphasis {compact
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
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}
|
||||
<div>
|
||||
<Badge color="gray" {large}>Job kind: {job.job_kind}</Badge>
|
||||
<Badge color="gray" {large}>Job kind: {getJobKindDisplayLabel(job.job_kind, job.script_path)}</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if job && job.flow_status && job.job_kind === 'script'}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
isScriptPreview,
|
||||
msToReadableTime,
|
||||
isFlowPreview,
|
||||
getJobKindDisplayLabel,
|
||||
getJobKindIcon
|
||||
} from '$lib/utils'
|
||||
import { Button } from '../common'
|
||||
@@ -155,12 +156,12 @@
|
||||
{/if}
|
||||
<JobKindIcon size={14} />
|
||||
</div>
|
||||
{#snippet text()}
|
||||
<span>
|
||||
{#if job && job.job_kind}
|
||||
{job.job_kind}
|
||||
{/if}
|
||||
{#if job && job.is_flow_step && job.parent_job}
|
||||
{#snippet text()}
|
||||
<span>
|
||||
{#if job && job.job_kind}
|
||||
{getJobKindDisplayLabel(job.job_kind, job.script_path)}
|
||||
{/if}
|
||||
{#if job && job.is_flow_step && job.parent_job}
|
||||
<br /> Step of flow
|
||||
<a href={`${base}/run/${job.parent_job}?workspace=${job.workspace_id}`}>
|
||||
{truncateRev(job.parent_job, 10)}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()}
|
||||
<h1 class="text-sm font-semibold text-primary">run/{page.params.run}</h1>
|
||||
{/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))}
|
||||
<Dropdown
|
||||
items={[
|
||||
@@ -547,16 +561,18 @@
|
||||
</Dropdown>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isFlowPreview(job?.job_kind) || isScriptPreview(job?.job_kind)}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
startIcon={{ icon: GitBranch }}
|
||||
on:click={forkPreview}
|
||||
>
|
||||
Fork {isFlowPreview(job?.job_kind) ? 'flow' : 'code'} preview
|
||||
</Button>
|
||||
{/if}
|
||||
{#if isFlowPreview(job?.job_kind) || isScriptPreview(job?.job_kind)}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
startIcon={{ icon: GitBranch }}
|
||||
on:click={forkPreview}
|
||||
>
|
||||
{isHubFlowPreview
|
||||
? 'Fork flow into workspace'
|
||||
: `Fork ${isFlowPreview(job?.job_kind) ? 'flow' : 'code'} preview`}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if persistentScriptDefinition !== undefined}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
|
||||
Reference in New Issue
Block a user