From 0249bb94ac2c7cfd3f3d8943e525056927d77b27 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 27 Oct 2026 01:24:09 +0100 Subject: [PATCH] refactor: harmonize flow and script settings layout (#12013) * refactor: harmonize flow and script settings layout Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the priority input open while it is edited Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep settings rows open while edited and name the deploying user for non-admins Co-Authored-By: Claude Opus 5.5 (1M context) * fix: write a setting before its row opens and show its real state while edited Co-Authored-By: Claude Opus 5.5 (1M context) * fix: slide settings rows open to their full height Co-Authored-By: Claude Opus 5.5 (1M context) * fix: remount a settings row's fields when its toggle turns it on Co-Authored-By: Claude Opus 5.5 (1M context) * fix: put the AI prompt behind a settings toggle Co-Authored-By: Claude Opus 5.5 * feat: summarize active runtime and permission settings on the metadata tab Co-Authored-By: Claude Opus 5.5 * refactor: move the active settings chips into the settings header Co-Authored-By: Claude Opus 5.5 * fix: bring back the active setting icons on the settings tabs Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Ruben Fiszel --- frontend/src/lib/assets/app.css | 10 +- .../components/JobTokenScopesSetting.svelte | 99 +- .../components/ScriptAdvancedSettings.svelte | 283 ----- .../src/lib/components/ScriptBuilder.svelte | 998 +++++----------- .../components/copilot/AIFormSettings.svelte | 16 +- .../details/ErrorHandlerToggleButtonV2.svelte | 80 -- .../lib/components/flows/DebounceLimit.svelte | 176 ++- .../flows/content/FlowEditorPanel.svelte | 2 +- .../flows/content/FlowModuleDebounce.svelte | 3 - .../flows/content/FlowRunSettings.svelte | 89 +- .../flows/content/FlowSettings.svelte | 1041 ++++++----------- .../flows/content/ScriptEditorDrawer.svelte | 8 +- .../WorkspaceScriptSettingsDrawer.svelte | 12 +- .../itemSettings/ActiveSettingChips.svelte | 73 ++ .../itemSettings/CacheSetting.svelte | 38 + .../ConcurrencyLimitSetting.svelte | 71 ++ .../DedicatedWorkersSetting.svelte | 41 + .../itemSettings/DeleteAfterUseSetting.svelte | 29 + .../itemSettings/InvisibleRunsSetting.svelte | 19 + .../MuteErrorHandlerSetting.svelte | 43 + .../itemSettings/PrioritySetting.svelte | 53 + .../itemSettings/RunOnBehalfOfSetting.svelte | 131 +++ .../itemSettings/ScriptRuntimeSettings.svelte | 140 +++ .../itemSettings/SettingGroup.svelte | 21 + .../itemSettings/SettingIcons.svelte | 27 + .../itemSettings/SettingToggle.svelte | 90 ++ .../itemSettings/WorkerTagSetting.svelte | 57 + .../itemSettings/onBehalfOf.test.ts | 25 + .../lib/components/itemSettings/onBehalfOf.ts | 43 + .../src/lib/components/scriptSettings.test.ts | 2 +- frontend/src/lib/components/scriptSettings.ts | 220 +++- 31 files changed, 1932 insertions(+), 2008 deletions(-) delete mode 100644 frontend/src/lib/components/ScriptAdvancedSettings.svelte delete mode 100644 frontend/src/lib/components/details/ErrorHandlerToggleButtonV2.svelte create mode 100644 frontend/src/lib/components/itemSettings/ActiveSettingChips.svelte create mode 100644 frontend/src/lib/components/itemSettings/CacheSetting.svelte create mode 100644 frontend/src/lib/components/itemSettings/ConcurrencyLimitSetting.svelte create mode 100644 frontend/src/lib/components/itemSettings/DedicatedWorkersSetting.svelte create mode 100644 frontend/src/lib/components/itemSettings/DeleteAfterUseSetting.svelte create mode 100644 frontend/src/lib/components/itemSettings/InvisibleRunsSetting.svelte create mode 100644 frontend/src/lib/components/itemSettings/MuteErrorHandlerSetting.svelte create mode 100644 frontend/src/lib/components/itemSettings/PrioritySetting.svelte create mode 100644 frontend/src/lib/components/itemSettings/RunOnBehalfOfSetting.svelte create mode 100644 frontend/src/lib/components/itemSettings/ScriptRuntimeSettings.svelte create mode 100644 frontend/src/lib/components/itemSettings/SettingGroup.svelte create mode 100644 frontend/src/lib/components/itemSettings/SettingIcons.svelte create mode 100644 frontend/src/lib/components/itemSettings/SettingToggle.svelte create mode 100644 frontend/src/lib/components/itemSettings/WorkerTagSetting.svelte create mode 100644 frontend/src/lib/components/itemSettings/onBehalfOf.test.ts create mode 100644 frontend/src/lib/components/itemSettings/onBehalfOf.ts diff --git a/frontend/src/lib/assets/app.css b/frontend/src/lib/assets/app.css index 4217c95831..705c22371e 100644 --- a/frontend/src/lib/assets/app.css +++ b/frontend/src/lib/assets/app.css @@ -414,12 +414,18 @@ input[type='time']::-webkit-calendar-picker-indicator { } /* Settings search highlight (used by both the drawer and the setup page) */ -[data-setting-key] { +[data-setting-key], +[data-setting] { transition: outline 0.8s ease; outline: 2px solid transparent; outline-offset: -2px; } -[data-setting-key].setting-highlight { +[data-setting].setting-highlight { + outline-offset: 4px; + border-radius: 4px; +} +[data-setting-key].setting-highlight, +[data-setting].setting-highlight { outline: 2px solid rgb(var(--color-border-accent, 59 130 246)); } diff --git a/frontend/src/lib/components/JobTokenScopesSetting.svelte b/frontend/src/lib/components/JobTokenScopesSetting.svelte index 6ef1ecf3c4..d36888982e 100644 --- a/frontend/src/lib/components/JobTokenScopesSetting.svelte +++ b/frontend/src/lib/components/JobTokenScopesSetting.svelte @@ -1,19 +1,16 @@ -
- { - if (value != null) { - value = null - } else { - setMode('oidc_only') - } - }} - options={{ - right: 'Restrict the job token', - rightTooltip: - "Limit what the WM_TOKEN of this " + - kind + - "'s jobs can do through the API, e.g. only mint an OIDC token. Use it for code that handles untrusted input, such as AI agents." - }} - /> - {#if value != null} - setMode(v)}> - {#snippet children({ item })} - - - - {/snippet} - - {#if mode === 'custom'} - value ?? [], (scopes) => (value = [...scopes])} - emptyLabel="No scopes selected. The job token can only reach its own job." - /> - {/if} -

- {#if kind === 'step'} - The WM_TOKEN of every job this step runs can only do what these scopes allow, on top - of the flow's own restriction. Loop and branch bodies, and AI agent tools, inherit it. - {:else} - The WM_TOKEN of every job of this {kind} can only do what these scopes allow. Jobs - it starts, {kind === 'flow' ? 'its steps, ' : ''}and AI agent tools inherit the restriction. - {/if} - Variables and resources passed as $var:/$res: inputs, an AI agent's provider - resource, relative imports and object storage each need a read scope, and script state - (getState/setState) needs resources:write. Write scopes on scripts, - flows, schedules or triggers let a job escape the restriction. Jobs with a restricted token never - run on dedicated workers. -

+ { + if (on) { + setMode('oidc_only') + } else { + value = null + } + }} +> + setMode(v)}> + {#snippet children({ item })} + + + + {/snippet} + + {#if mode === 'custom'} + value ?? [], (scopes) => (value = [...scopes])} + emptyLabel="No scopes selected. The job token can only reach its own job." + /> {/if} -
+

+ {#if kind === 'step'} + The WM_TOKEN of every job this step runs can only do what these scopes allow, on top + of the flow's own restriction. Loop and branch bodies, and AI agent tools, inherit it. + {:else} + The WM_TOKEN of every job of this {kind} can only do what these scopes allow. Jobs + it starts, {kind === 'flow' ? 'its steps, ' : ''}and AI agent tools inherit the restriction. + {/if} + Variables and resources passed as $var:/$res: inputs, an AI agent's + provider resource, relative imports and object storage each need a read scope, and script state + (getState/setState) needs resources:write. Write scopes + on scripts, flows, schedules or triggers let a job escape the restriction. Jobs with a + restricted token never run on dedicated workers. +

+ diff --git a/frontend/src/lib/components/ScriptAdvancedSettings.svelte b/frontend/src/lib/components/ScriptAdvancedSettings.svelte deleted file mode 100644 index a42c2e8909..0000000000 --- a/frontend/src/lib/components/ScriptAdvancedSettings.svelte +++ /dev/null @@ -1,283 +0,0 @@ - - -
-
- {#snippet header()} - - The script will be executed on a worker configured to listen to this worker group tag - (queue). For instance, you could setup an "highmem", or "gpu" tag. - - {/snippet} - -
- -
- {#snippet header()} - - Allowed concurrency within a given timeframe - - {/snippet} - { - if (script.concurrent_limit && script.concurrent_limit != undefined) { - script.concurrent_limit = undefined - script.concurrency_time_window_s = undefined - script.concurrency_key = undefined - } else { - script.concurrent_limit = 1 - } - }} - options={{ right: 'Concurrency limits' }} - /> - {#if Boolean(script.concurrent_limit)} -
- - - -
- {/if} -
- -
- {#snippet header()} - - Cache the results for each possible inputs - - {/snippet} -
- !!script.cache_ttl, (v) => (script.cache_ttl = v ? 300 : undefined)} - options={{ right: 'Cache the results for each possible inputs' }} - /> - {#if script.cache_ttl} -
How long to keep the cache valid
- - script.cache_ignore_s3_path, (v) => (script.cache_ignore_s3_path = v || undefined) - } - options={{ - right: 'Ignore S3 Object paths for caching purposes', - rightTooltip: - 'If two S3 objects passed as input have the same content, they will hit the same cache entry, regardless of their path.' - }} - /> - {/if} -
-
- -
- {#snippet header()} - - Add a custom timeout for this script - - {/snippet} -
- { - if (script.timeout && script.timeout != undefined) { - script.timeout = undefined - } else { - script.timeout = 300 - } - }} - options={{ right: 'Add a custom timeout for this script' }} - /> - {#if Boolean(script.timeout)} - Timeout duration - - {/if} -
-
- -
- {#snippet header()} - - Debounce Jobs - - {/snippet} - -
- -
- {#snippet header()} - - Restart the script upon ending unless cancelled - - {/snippet} - { - script.restart_unless_cancelled = script.restart_unless_cancelled ? undefined : true - }} - options={{ right: 'Restart upon ending unless cancelled' }} - /> -
- -
- {#snippet header()} - - In this mode, the script is meant to be run on dedicated workers that run the script at - native speed. Can reach >1500rps per dedicated worker. Only available on enterprise - edition and for Python3, Deno, Bun and Bunnative. - - {/snippet} - { - script.dedicated_worker = script.dedicated_worker ? undefined : true - }} - options={{ right: 'Script is run on dedicated workers' }} - /> - {#if script.dedicated_worker} -
- - A worker group needs to be configured to listen to this script. Select it in the dedicated - workers section of the worker group configuration. - -
- {/if} -
- -
- {#snippet header()} - - The logs, arguments and results of the job will be completely deleted from Windmill after - the specified delay once it is complete. Set to 0 for immediate deletion. The deletion is - irreversible. This settings ONLY applies when the script is used within a flow or triggered - synchronously. - - {/snippet} -
- { - script.delete_after_secs = script.delete_after_secs != null ? undefined : 0 - }} - options={{ right: 'Delete logs, arguments and results after completion' }} - /> - {#if script.delete_after_secs != null} - - {/if} -
-
- - {#if !isCloudHosted()} -
- {#snippet header()} - - Jobs from script labeled as high priority take precedence over the other jobs when in the - jobs queue. - {#if !$enterpriseLicense}This is a feature only available on enterprise edition.{/if} - - {/snippet} - 0} - on:change={() => { - script.priority = script.priority ? undefined : 100 - }} - options={{ right: 'Label as high priority' }} - > - {#snippet right()} - { - if (script.priority && script.priority > 100) { - script.priority = 100 - } else if (script.priority && script.priority < 0) { - script.priority = 0 - } - }} - /> - {/snippet} - -
- {/if} -
diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 7c6db7b242..0e7b25fc99 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -1,7 +1,4 @@ {#if !$copilotInfo.workspaceDisabled} - + {/if} diff --git a/frontend/src/lib/components/details/ErrorHandlerToggleButtonV2.svelte b/frontend/src/lib/components/details/ErrorHandlerToggleButtonV2.svelte deleted file mode 100644 index 2aec483e32..0000000000 --- a/frontend/src/lib/components/details/ErrorHandlerToggleButtonV2.svelte +++ /dev/null @@ -1,80 +0,0 @@ - - - diff --git a/frontend/src/lib/components/flows/DebounceLimit.svelte b/frontend/src/lib/components/flows/DebounceLimit.svelte index 3ab55caeed..548e5a91fe 100644 --- a/frontend/src/lib/components/flows/DebounceLimit.svelte +++ b/frontend/src/lib/components/flows/DebounceLimit.svelte @@ -1,11 +1,12 @@ -
-
- { - if (debounce_delay_s !== undefined) { - debounce_delay_s = undefined - debounce_key = undefined - } else { - debounce_delay_s = 1 - } - }} - options={{ - right: 'Debouncing', - rightTooltip: 'Consolidate multiple executions into a single run within a time window', - rightDocumentationLink: 'https://www.windmill.dev/docs/core_concepts/job_debouncing' - }} - class="py-1" - eeOnly={true} + + { + if (on) { + debounce_delay_s = 1 + } else { + debounce_delay_s = undefined + debounce_key = undefined + } + }} +> + + + - - - -
- {/if} -
+ {#if arrayArgs.length === 0} +
+ No list argument in the schema. Add an argument of type "array" to accumulate it. +
+ {/if} + + + + diff --git a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte index 404999dfbc..f99fb91dee 100644 --- a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte +++ b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte @@ -119,7 +119,7 @@ resolvedCount={resolvedModuleIds.length} /> {:else if selectedId?.startsWith('settings')} - + {:else if selectedId === 'Input'} -${flowModule.id}`} - size="xs" - fontClass="text-xs font-normal text-primary" - indentContent /> diff --git a/frontend/src/lib/components/flows/content/FlowRunSettings.svelte b/frontend/src/lib/components/flows/content/FlowRunSettings.svelte index 2d91ab42a7..a39f74c2d7 100644 --- a/frontend/src/lib/components/flows/content/FlowRunSettings.svelte +++ b/frontend/src/lib/components/flows/content/FlowRunSettings.svelte @@ -29,6 +29,7 @@ import FlowModuleDebounce from './FlowModuleDebounce.svelte' import FlowModuleMock from './FlowModuleMock.svelte' import JobTokenScopesSetting from '$lib/components/JobTokenScopesSetting.svelte' + import SettingGroup from '$lib/components/itemSettings/SettingGroup.svelte' import WorkspaceScriptSettingInfo from './WorkspaceScriptSettingInfo.svelte' import { slideDynamic } from '$lib/transitions' @@ -117,21 +118,13 @@ ) -{#snippet sectionHeader(title: string)} -
- {title} -
-{/snippet} -
{#if !isFailure} -
- {@render sectionHeader('Flow control')} - +
@@ -148,13 +141,11 @@
-
{/if} -
- {@render sectionHeader('Execution policy')} - +
{#if !loopSubset}
@@ -348,48 +339,42 @@
{/if} -
- -
-
+
{#if s3Language && onApplyS3Snippet && !isFailure} -
- {@render sectionHeader('S3 snippets')} -

- Read and write S3 objects, and use Polars or DuckDB to run efficient ETL processes. -

-
- - {#snippet children({ item })} - {#if s3Language === 'deno'} - - {:else} - - - - {/if} - {/snippet} - - -
- {#if s3Snippet} -
- +
+ +

+ Read and write S3 objects, and use Polars or DuckDB to run efficient ETL processes. +

+
+ + {#snippet children({ item })} + {#if s3Language === 'deno'} + + {:else} + + + + {/if} + {/snippet} + +
- {/if} -
+ {#if s3Snippet} +
+ +
+ {/if} + + {/if} diff --git a/frontend/src/lib/components/flows/content/FlowSettings.svelte b/frontend/src/lib/components/flows/content/FlowSettings.svelte index dacb9efd4d..12da7f16f9 100644 --- a/frontend/src/lib/components/flows/content/FlowSettings.svelte +++ b/frontend/src/lib/components/flows/content/FlowSettings.svelte @@ -1,36 +1,42 @@ +{#snippet sameWorkerConflict()} + {SAME_WORKER_INCOMPATIBLE_MSG} Remove them from step{conflictingModuleIds.length > 1 ? 's' : ''} + {conflictingModuleIds.join(', ')} first. +{/snippet} +
- -
- -
- - - - - {#if !noEditor && customUi?.topBar?.editablePath != false} - - {/if} - - + + {#snippet header()} + +
+
Settings
+
+ {/snippet} +
+ + + + {#snippet extra()} + + {/snippet} + + + {#snippet extra()} + + {/snippet} + - -
- - {#if customUi?.settingsTabs?.workerGroup != false} -
- { - displayWorkerTagPicker = !displayWorkerTagPicker - if (!displayWorkerTagPicker) { - // Clearing the tag triggers the reactive block above, which also - // resets preserve_step_tags so it doesn't linger as invisible state. - flowStore.val.tag = undefined - } - }} - options={{ - right: 'Worker group tag (queue)', - rightTooltip: - "When a worker group tag is defined at the flow level, any steps inside the flow will run on any worker group that listen to that tag, regardless of the steps tag. If no worker group tags is defined, the flow controls will be executed with the default tag 'flow' and the steps will be executed with their respective tag", - rightDocumentationLink: 'https://www.windmill.dev/docs/core_concepts/worker_groups' - }} - /> - - {#if displayWorkerTagPicker} -
- -
- {#if flowStore.val.tag} -
- + +
+
- {/if} - {/if} -
+ + + - - {/if} + {#if !noEditor && customUi?.topBar?.editablePath != false} + + {/if} - - {#snippet badge()} - {#if numberOfAdvancedOptionsOn > 0} -
- {#each activeAdvancedOptionNames as optionName} - {optionName} - {/each} -
- {/if} - {/snippet} - - - {#if customUi?.settingsTabs?.cache != false} -
- { - if (flowStore.val.value.cache_ttl && flowStore.val.value.cache_ttl != undefined) { - flowStore.val.value.cache_ttl = undefined - } else { - flowStore.val.value.cache_ttl = 300 - } - }} - options={{ - right: 'Cache the results for each possible inputs', - rightTooltip: - 'When enabled, the flow will cache the results of the flow for each possible set of inputs.', - rightDocumentationLink: 'https://www.windmill.dev/docs/flows/cache#cache-flows' - }} - /> - {#if flowStore.val.value.cache_ttl} -
-
How long to keep the cache valid
- - flowStore.val.value.cache_ignore_s3_path, - (v) => (flowStore.val.value.cache_ignore_s3_path = v || undefined) - } - options={{ - right: 'Ignore S3 Object paths for caching purposes', - rightTooltip: - 'If two S3 objects passed as input have the same content, they will hit the same cache entry, regardless of their path.' - }} - /> -
- {/if} -
- {/if} - - - {#if customUi?.settingsTabs?.earlyStop != false} -
- - { - if (Boolean(flowStore.val.value.skip_expr) && flowStore.val.value.skip_expr) { - flowStore.val.value.skip_expr = undefined - } else { - flowStore.val.value.skip_expr = 'flow_input.foo == undefined' - } - }} - options={{ - right: 'Early stop if condition met', - rightTooltip: - 'If the inputs meet the predefined condition, the flow will not run.' + - 'to decide if the flow should stop early.', - rightDocumentationLink: - 'https://www.windmill.dev/docs/flows/early_stop#early-stop-for-flow' - }} - /> - {#if flowStore.val.value.skip_expr} -
-
- + -
- You can use the variable `flow_input` to access the inputs of the flow.
The variable `WM_SCHEDULED_FOR` contains the time the flow was scheduled for - which you can use to stop early non fresh jobs: -
new Date().getTime() - new Date(WM_SCHEDULED_FOR).getTime() {'>'} X
-
-
-
- {/if} -
- {/if} + - - {#if customUi?.settingsTabs?.earlyReturn != false} -
- { - if (Boolean(flowStore.val.value.early_return) && flowStore.val.value.early_return) { - flowStore.val.value.early_return = undefined - } else { - flowStore.val.value.early_return = flowStore.val.value.modules?.[0]?.id ?? 'a' - } - }} - options={{ - right: 'Early return for sync webhooks', - rightTooltip: - 'If defined, sync endpoints will return early at the node defined here while the rest of the flow continue asynchronously.', - rightDocumentationLink: 'https://www.windmill.dev/docs/flows/early_return' - }} - /> - {#if flowStore.val.value.early_return} - {@const candidates = earlyReturnCandidates(flowStore.val.value.modules ?? [])} - {@const selected = candidates.find((c) => c.id === flowStore.val.value.early_return)} -
- ({ value: c.id, label: c.id }))} + bind:value={flowStore.val.value.early_return} + placeholder="Node's id" + /> +
+ {#if !selected} + + Step {flowStore.val.value.early_return} is not one of the steps above. Pick + another step. + + {:else if !selected.deterministic} + + This step only runs if its branch is taken. Otherwise, sync calls return + the flow's final result. + + {/if} + + {/if} + + {/if} - - { - if (flowStore.val.visible_to_runner_only) { - flowStore.val.visible_to_runner_only = undefined - } else { - flowStore.val.visible_to_runner_only = true - } - }} - options={{ - right: 'Make runs invisible to others', - rightTooltip: - 'When this option is enabled, manual executions of this script are invisible to users other than the user running it, including the owner(s). This setting can be overridden when this script is run manually from the advanced menu.', - rightDocumentationLink: - 'https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs#invisible-runs' - }} - /> - - - - { - if (flowStore.val.on_behalf_of_email) { - flowStore.val.on_behalf_of_email = undefined - flowStore.val.on_behalf_of = undefined - $preserveOnBehalfOf = false - onBehalfOfChoice = undefined - } else { - flowStore.val.on_behalf_of_email = $userStore?.email - flowStore.val.on_behalf_of = myPermissionedAs - } - }} - options={{ - right: `Run on behalf of ${canPreserve ? 'a specified user' : 'last editor'}`, - rightTooltip: - 'When this option is enabled, the flow will be run with the permissions of the corresponding user.' - }} - /> - {#if flowStore.val.on_behalf_of_email && canPreserve} - → { - onBehalfOfChoice = choice - if (choice === 'me') { - flowStore.val.on_behalf_of_email = $userStore?.email - flowStore.val.on_behalf_of = myPermissionedAs - customOnBehalfOfEmail = '' - $preserveOnBehalfOf = false - } else if (choice === 'target') { - // Keep the saved pair. A flow that has no recorded principal yet - // sends the email alone and the backend derives one from it. - flowStore.val.on_behalf_of_email = $savedOnBehalfOfEmail - flowStore.val.on_behalf_of = $savedOnBehalfOfPermissionedAs - customOnBehalfOfEmail = '' - $preserveOnBehalfOf = true - } else if (choice === 'custom' && details) { - flowStore.val.on_behalf_of_email = details.email - flowStore.val.on_behalf_of = details.permissionedAs - customOnBehalfOfEmail = details.email - $preserveOnBehalfOf = true - } - }} - kind="flow" - {canPreserve} - customValue={customOnBehalfOfEmail} - isDeployment={false} - /> - {:else if flowStore.val.on_behalf_of_email && !canPreserve} - - Currently: {$savedOnBehalfOfEmail ?? flowStore.val.on_behalf_of_email}. Will be set to {$userStore?.email} on deploy (requires - admin or wm_deployers group to override) - - {/if} - - - -
- - {#if !$enterpriseLicense} - - {/if} -
- - - {#if customUi?.settingsTabs?.concurrency != false} -
- { - if (flowStore.val.value.concurrent_limit) { - flowStore.val.value.concurrent_limit = undefined - } else { - flowStore.val.value.concurrent_limit = 1 - } - }} - options={{ - right: 'Concurrency limits', - rightTooltip: 'Allowed concurrency within a given timeframe', - rightDocumentationLink: 'https://www.windmill.dev/docs/flows/concurrency_limit' - }} - eeOnly={true} - /> - - {#if flowStore.val.value.concurrent_limit} -
- - - + + + + {#if customUi?.settingsTabs?.workerGroup != false} + + {/if} + + {#if customUi?.settingsTabs?.sharedDiretory != false} + 0} + checked={!!flowStore.val.value.same_worker} + onchange={(on) => (flowStore.val.value.same_worker = on)} + hint={conflictingModuleIds.length > 0 ? sameWorkerConflict : undefined} + /> + {/if} + + + + +
- {/if} + + + +
+ + + +
+
- {/if} - - - {#if customUi?.settingsTabs?.debouncing != false} - - {/if} - - - 0} - on:change={() => { - if (flowStore.val.value.priority) { - flowStore.val.value.priority = undefined - } else { - flowStore.val.value.priority = 100 - } - }} - eeOnly={true} - options={{ - right: `Label as high priority`, - rightTooltip: `All jobs scheduled by flows labeled as high priority take precedence over the other jobs in the jobs queue. Higher priority numbers are executed first.`, - rightDocumentationLink: 'https://www.windmill.dev/docs/flows/priority' - }} - > - {#snippet right()} - { - if (flowStore.val.value.priority && flowStore.val.value.priority > 100) { - flowStore.val.value.priority = 100 - } else if (flowStore.val.value.priority && flowStore.val.value.priority < 0) { - flowStore.val.value.priority = 0 - } - }} - /> - {/snippet} - - - { - if (flowStore.val.value.delete_after_secs != null) { - flowStore.val.value.delete_after_secs = undefined - } else { - flowStore.val.value.delete_after_secs = 0 - } - }} - options={{ - right: 'Delete all step results after completion', - rightTooltip: `When enabled, the logs, arguments and results of all flow steps will be deleted after the specified delay once the flow completes. Set to 0 for immediate deletion. The deletion is irreversible.` - }} - eeOnly={true} - /> - {#if flowStore.val.value.delete_after_secs != null} -
- -
- {/if} - -
- { - if (flowStore.val.dedicated_worker) { - flowStore.val.dedicated_worker = undefined - } else { - flowStore.val.dedicated_worker = true - } - }} - options={{ - right: 'Flow is run on dedicated workers', - rightTooltip: 'When enabled, the flow will be executed on a dedicated worker.', - rightDocumentationLink: - 'https://www.windmill.dev/docs/core_concepts/jobs#high-priority-jobs' - }} - eeOnly={true} - /> - - {#if flowStore.val.dedicated_worker} -
- - A worker group needs to be configured to listen to this flow. Select it in the - dedicated workers section of the worker group configuration. - -
- {/if} -
- {#if flowStore.val.schema && enableAi} - - {/if} - - -
+ {/snippet} +
diff --git a/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte b/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte index ee9d646419..f14711fc10 100644 --- a/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte +++ b/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte @@ -6,7 +6,7 @@ import { ScriptService, type Preview, type Script } from '$lib/gen' import { inferArgs } from '$lib/infer' import { Loader2, Save, DiffIcon, Settings } from 'lucide-svelte' - import ScriptAdvancedSettings from '$lib/components/ScriptAdvancedSettings.svelte' + import ScriptRuntimeSettings from '$lib/components/itemSettings/ScriptRuntimeSettings.svelte' import ScriptSettingsBadges from '$lib/components/ScriptSettingsBadges.svelte' import Popover from '$lib/components/Popover.svelte' import { @@ -348,17 +348,17 @@ /> - settingsDrawer?.closeDrawer()}> + settingsDrawer?.closeDrawer()}> {#if script}

These runtime settings are saved together with the script when you press Save.

- +
{/if} {#snippet actions()} - + {/snippet}
diff --git a/frontend/src/lib/components/flows/content/WorkspaceScriptSettingsDrawer.svelte b/frontend/src/lib/components/flows/content/WorkspaceScriptSettingsDrawer.svelte index f9ed5f671d..9f644fb960 100644 --- a/frontend/src/lib/components/flows/content/WorkspaceScriptSettingsDrawer.svelte +++ b/frontend/src/lib/components/flows/content/WorkspaceScriptSettingsDrawer.svelte @@ -1,7 +1,7 @@ - drawer?.closeDrawer()}> + drawer?.closeDrawer()}> {#if loading}
@@ -118,8 +118,8 @@ {#if current}
{/if} {#snippet actions()} diff --git a/frontend/src/lib/components/itemSettings/ActiveSettingChips.svelte b/frontend/src/lib/components/itemSettings/ActiveSettingChips.svelte new file mode 100644 index 0000000000..39c902d6fa --- /dev/null +++ b/frontend/src/lib/components/itemSettings/ActiveSettingChips.svelte @@ -0,0 +1,73 @@ + + + + +{#snippet chip({ tab, badge }: { tab: string; badge: ScriptSettingsBadge })} + onopen(tab, badge.setting)} + > + {badge.short} + +{/snippet} + +{#if chips.length > 0} +
+ {#each shown as c (c.badge.key)} + {@render chip(c)} + {/each} + {#if rest.length > 0} + + {#snippet trigger()} + +{rest.length} + {/snippet} + {#snippet content()} +
+ {#each rest as c (c.badge.key)} + {@render chip(c)} + {/each} +
+ {/snippet} +
+ {/if} +
+{/if} diff --git a/frontend/src/lib/components/itemSettings/CacheSetting.svelte b/frontend/src/lib/components/itemSettings/CacheSetting.svelte new file mode 100644 index 0000000000..2bb8ad97ad --- /dev/null +++ b/frontend/src/lib/components/itemSettings/CacheSetting.svelte @@ -0,0 +1,38 @@ + + + 0} + onchange={(on) => (ttl = on ? 300 : undefined)} +> + + ignoreS3Path, (v) => (ignoreS3Path = v || undefined)} + options={{ + right: 'Ignore S3 object paths', + rightTooltip: + 'Two S3 objects with the same content hit the same cache entry, whatever their path.' + }} + /> + diff --git a/frontend/src/lib/components/itemSettings/ConcurrencyLimitSetting.svelte b/frontend/src/lib/components/itemSettings/ConcurrencyLimitSetting.svelte new file mode 100644 index 0000000000..bf896b3a74 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/ConcurrencyLimitSetting.svelte @@ -0,0 +1,71 @@ + + + 0} + onchange={(on) => { + if (on) { + limit = 1 + } else { + limit = undefined + // A flow keeps its window and key in its value when the limit goes; a script drops them. + if (kind === 'script') { + timeWindowS = undefined + concurrencyKey = undefined + } + } + }} +> + + + + diff --git a/frontend/src/lib/components/itemSettings/DedicatedWorkersSetting.svelte b/frontend/src/lib/components/itemSettings/DedicatedWorkersSetting.svelte new file mode 100644 index 0000000000..592a8e21a3 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/DedicatedWorkersSetting.svelte @@ -0,0 +1,41 @@ + + +{#snippet reason()} + {unsupportedReason} +{/snippet} + + +{#if !isCloudHosted()} + (dedicatedWorker = on || undefined)} + hint={unsupportedReason ? reason : undefined} + > + + A worker group must listen to this {kind}. Select it in the dedicated workers section of the + worker group configuration. + + +{/if} diff --git a/frontend/src/lib/components/itemSettings/DeleteAfterUseSetting.svelte b/frontend/src/lib/components/itemSettings/DeleteAfterUseSetting.svelte new file mode 100644 index 0000000000..0ac1449230 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/DeleteAfterUseSetting.svelte @@ -0,0 +1,29 @@ + + + (deleteAfterSecs = on ? 0 : undefined)} +> + + diff --git a/frontend/src/lib/components/itemSettings/InvisibleRunsSetting.svelte b/frontend/src/lib/components/itemSettings/InvisibleRunsSetting.svelte new file mode 100644 index 0000000000..883ba5b816 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/InvisibleRunsSetting.svelte @@ -0,0 +1,19 @@ + + + (visibleToRunnerOnly = on || undefined)} +/> diff --git a/frontend/src/lib/components/itemSettings/MuteErrorHandlerSetting.svelte b/frontend/src/lib/components/itemSettings/MuteErrorHandlerSetting.svelte new file mode 100644 index 0000000000..bbb9c37514 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/MuteErrorHandlerSetting.svelte @@ -0,0 +1,43 @@ + + +{#snippet eeHint()} + Enterprise Edition only. +{/snippet} + + { + pending = on + toggle() + }} + hint={$enterpriseLicense ? undefined : eeHint} +/> diff --git a/frontend/src/lib/components/itemSettings/PrioritySetting.svelte b/frontend/src/lib/components/itemSettings/PrioritySetting.svelte new file mode 100644 index 0000000000..99a36543e4 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/PrioritySetting.svelte @@ -0,0 +1,53 @@ + + + +{#if !isCloudHosted()} + 0} + onchange={(on) => (priority = on ? 100 : undefined)} + > + + +{/if} diff --git a/frontend/src/lib/components/itemSettings/RunOnBehalfOfSetting.svelte b/frontend/src/lib/components/itemSettings/RunOnBehalfOfSetting.svelte new file mode 100644 index 0000000000..815254fab4 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/RunOnBehalfOfSetting.svelte @@ -0,0 +1,131 @@ + + +{#snippet valueHint()} + Lets people run this {kind} with resources and variables they can't access themselves. +{/snippet} + + { + if (on) { + email = $userStore?.email + permissionedAs = myPermissionedAs + } else { + email = undefined + permissionedAs = undefined + preserve = false + choice = undefined + } + }} +> +
+ { + choice = c + if (c === 'me') { + email = $userStore?.email + permissionedAs = myPermissionedAs + preserve = false + } else if (c === 'target') { + // Keep the saved pair. An item that has no recorded principal yet sends the + // email alone and the backend derives one from it. + email = savedEmail + permissionedAs = savedPermissionedAs + preserve = true + } else if (c === 'custom' && details) { + email = details.email + permissionedAs = details.permissionedAs + preserve = true + } + }} + {kind} + {canPreserve} + customValue={choice === 'custom' ? email : undefined} + isDeployment={false} + readOnlyWithoutPreserve + /> + {#if willChange} + + Changes from {savedPermissionedAs} on deploy + {/if} +
+ {#if !canPreserve} +
+ Only workspace admins and members of {WM_DEPLOYERS_GROUP} can pick another user. +
+ {/if} +
+ Every run uses the access of {chosenUser}. Who started it is still recorded, and the code reads + their email from WM_END_USER_EMAIL. +
+
diff --git a/frontend/src/lib/components/itemSettings/ScriptRuntimeSettings.svelte b/frontend/src/lib/components/itemSettings/ScriptRuntimeSettings.svelte new file mode 100644 index 0000000000..6fb657b10e --- /dev/null +++ b/frontend/src/lib/components/itemSettings/ScriptRuntimeSettings.svelte @@ -0,0 +1,140 @@ + + +
+ + (script.timeout = on ? defaultTimeout : undefined)} + > + + + + + + + (script.restart_unless_cancelled = on || undefined)} + /> + + + + + + + {#if !isCloudHosted()} + 0} + onchange={(on) => { + addingEnvs = on + script.envs = on ? [''] : [] + }} + > + + Only the deployed script gets these variables. + + {#each script.envs ?? [] as _, i (i)} +
+ {#if script.envs} + + {/if} +
+ {/each} +
+ +
+
+ {/if} +
+
diff --git a/frontend/src/lib/components/itemSettings/SettingGroup.svelte b/frontend/src/lib/components/itemSettings/SettingGroup.svelte new file mode 100644 index 0000000000..993e4b3a60 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/SettingGroup.svelte @@ -0,0 +1,21 @@ + + + +
+
{label}
+
+ {@render children?.()} +
+
diff --git a/frontend/src/lib/components/itemSettings/SettingIcons.svelte b/frontend/src/lib/components/itemSettings/SettingIcons.svelte new file mode 100644 index 0000000000..289fee3e56 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/SettingIcons.svelte @@ -0,0 +1,27 @@ + + + +{#if badges.length > 0} +
+ {#each badges as badge (badge.key)} + {@const Icon = badge.icon} + + + {/each} +
+{/if} diff --git a/frontend/src/lib/components/itemSettings/SettingToggle.svelte b/frontend/src/lib/components/itemSettings/SettingToggle.svelte new file mode 100644 index 0000000000..3b53644692 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/SettingToggle.svelte @@ -0,0 +1,90 @@ + + +
+ checked, + (v) => { + editing = false + // The host writes the value here, before the row opens. A click runs microtasks between + // the input's listeners, so a `change` listener would come after the parameters mounted + // on the old value, and an editor that reads its code once at mount would open empty. + onchange?.(!!v) + if (v) generation++ + } + } + {disabled} + {eeOnly} + {aiId} + {aiDescription} + options={{ right: label, rightTooltip: tooltip, rightDocumentationLink: documentationLink }} + /> + {#if hint} + +
{@render hint()}
+ {/if} + {#if (checked || editing) && children} + +
(editing = true)}> + {#key generation} +
+ {@render children()} +
+ {/key} +
+ {/if} +
diff --git a/frontend/src/lib/components/itemSettings/WorkerTagSetting.svelte b/frontend/src/lib/components/itemSettings/WorkerTagSetting.svelte new file mode 100644 index 0000000000..497cb2bec3 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/WorkerTagSetting.svelte @@ -0,0 +1,57 @@ + + + { + picking = on + if (!on) { + tag = undefined + preserveStepTags = undefined + } + }} +> + + {#if kind === 'flow' && tag} + + {/if} + diff --git a/frontend/src/lib/components/itemSettings/onBehalfOf.test.ts b/frontend/src/lib/components/itemSettings/onBehalfOf.test.ts new file mode 100644 index 0000000000..04c55d3666 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/onBehalfOf.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' +import { deployedPermissionedAs, keepsDeployedOnBehalfOf } from './onBehalfOf' + +// Who a deploy leaves the item running on behalf of: only admins and wm_deployers keep another user. +describe('onBehalfOf', () => { + const admin = { username: 'admin', is_admin: true } + const deployer = { username: 'dep', groups: ['wm_deployers'] } + const member = { username: 'alice' } + + it('keeps the held user only for a deployer who may preserve', () => { + expect(deployedPermissionedAs(admin, 'u/bot')).toBe('u/bot') + expect(deployedPermissionedAs(deployer, 'u/bot')).toBe('u/bot') + expect(deployedPermissionedAs(admin, undefined)).toBe('u/admin') + expect(deployedPermissionedAs(member, 'u/bot')).toBe('u/alice') + }) + + it('picks the deployed user only when nothing is picked and run-as is on', () => { + expect(keepsDeployedOnBehalfOf(admin, undefined, 'bot@x.dev', 'bot@x.dev')).toBe(true) + expect(keepsDeployedOnBehalfOf(deployer, undefined, 'me@x.dev', 'bot@x.dev')).toBe(true) + expect(keepsDeployedOnBehalfOf(admin, 'me', 'me@x.dev', 'bot@x.dev')).toBe(false) + expect(keepsDeployedOnBehalfOf(admin, undefined, undefined, 'bot@x.dev')).toBe(false) + expect(keepsDeployedOnBehalfOf(admin, undefined, 'me@x.dev', undefined)).toBe(false) + expect(keepsDeployedOnBehalfOf(member, undefined, 'bot@x.dev', 'bot@x.dev')).toBe(false) + }) +}) diff --git a/frontend/src/lib/components/itemSettings/onBehalfOf.ts b/frontend/src/lib/components/itemSettings/onBehalfOf.ts new file mode 100644 index 0000000000..803fa5f45a --- /dev/null +++ b/frontend/src/lib/components/itemSettings/onBehalfOf.ts @@ -0,0 +1,43 @@ +export const WM_DEPLOYERS_GROUP = 'wm_deployers' + +type DeployingUser = + | { + username?: string + email?: string + is_admin?: boolean + is_super_admin?: boolean + groups?: string[] + } + | undefined + +/** Who may keep, or pick, another user's identity on deploy. For anyone else the backend deploys + * the item on behalf of the deployer, whatever the request says. */ +export function canPreserveOnBehalfOf(user: DeployingUser): boolean { + return ( + !!user?.is_admin || + !!user?.is_super_admin || + (user?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false) + ) +} + +/** Whether the deployed user should be picked, as the run-as selector does once it shows with + * nothing picked. The Runtime tab applies it too, so a deploy that only changes a runtime + * setting keeps sending `preserve_on_behalf_of`. */ +export function keepsDeployedOnBehalfOf( + user: DeployingUser, + choice: string | undefined, + email: string | undefined, + savedEmail: string | undefined +): boolean { + return choice === undefined && !!email && !!savedEmail && canPreserveOnBehalfOf(user) +} + +/** The `u/username` a deploy leaves the item running on behalf of: the held user for a deployer + * who may preserve, the deployer for anyone else. */ +export function deployedPermissionedAs( + user: DeployingUser, + permissionedAs: string | undefined +): string | undefined { + if (permissionedAs && canPreserveOnBehalfOf(user)) return permissionedAs + return user?.username ? `u/${user.username}` : undefined +} diff --git a/frontend/src/lib/components/scriptSettings.test.ts b/frontend/src/lib/components/scriptSettings.test.ts index f010e3863a..92d8722a2f 100644 --- a/frontend/src/lib/components/scriptSettings.test.ts +++ b/frontend/src/lib/components/scriptSettings.test.ts @@ -16,7 +16,7 @@ describe('getActiveScriptSettingsBadges', () => { priority: 50, tag: 'gpu' }).map((b) => b.key) - expect(keys).toEqual(['concurrency', 'cache', 'timeout', 'priority', 'tag']) + expect(keys).toEqual(['timeout', 'concurrency', 'cache', 'priority', 'tag']) }) it('treats a zero/absent priority and non-positive debounce as inactive', () => { diff --git a/frontend/src/lib/components/scriptSettings.ts b/frontend/src/lib/components/scriptSettings.ts index 4a3b3ad5b8..6782c526bc 100644 --- a/frontend/src/lib/components/scriptSettings.ts +++ b/frontend/src/lib/components/scriptSettings.ts @@ -1,19 +1,25 @@ import { - Gauge, - Database, - Timer, - Hourglass, - Repeat, - Cpu, - Trash2, ChevronsUp, - Tag + CircleStop, + Combine, + CornerDownLeft, + Cpu, + Database, + EyeOff, + FolderOpen, + Gauge, + KeyRound, + Repeat, + Tag, + Timer, + Trash2, + UserCog } from 'lucide-svelte' -import type { ScriptLang } from '$lib/gen' +import type { FlowValue, ScriptLang } from '$lib/gen' -// Subset of Script/NewScript fields that make up the "advanced runtime settings" -// surfaced both in the standalone script editor and, via the mini settings drawer, -// from within the flow editor for workspace-script steps. +// Subset of Script/NewScript fields that make up the runtime settings surfaced both in the +// standalone script editor and, via the mini settings drawer, from within the flow editor for +// workspace-script steps. export type ScriptAdvancedSettingsFields = { path?: string language?: ScriptLang @@ -34,27 +40,94 @@ export type ScriptAdvancedSettingsFields = { dedicated_worker?: boolean delete_after_secs?: number priority?: number + envs?: string[] } export type ScriptSettingsBadge = { key: string label: string + /** The label in a word or two, for the chips in the settings header. */ + short: string + /** The `data-setting` of the setting's row in its tab. */ + setting: string icon: any detail: string } -// Compute the list of active advanced settings for a script, used to render -// at-a-glance badges in the editor top bar and in the flow drawers. +/** Runtime settings only a flow has. */ +export type FlowOnlyRuntimeFields = { + skip_expr?: string + early_return?: string + same_worker?: boolean +} + +/** A flow's runtime settings, which it splits between itself and its value, in the shape the + * badges read. */ +export function flowRuntimeFields(flow: { + tag?: string + dedicated_worker?: boolean + value: FlowValue +}): ScriptAdvancedSettingsFields & FlowOnlyRuntimeFields { + const v = flow.value + return { + tag: flow.tag, + dedicated_worker: flow.dedicated_worker, + concurrent_limit: v.concurrent_limit, + concurrency_time_window_s: v.concurrency_time_window_s, + cache_ttl: v.cache_ttl, + debounce_delay_s: v.debounce_delay_s, + delete_after_secs: v.delete_after_secs, + priority: v.priority, + skip_expr: v.skip_expr, + early_return: v.early_return, + same_worker: v.same_worker + } +} + +// The active runtime settings of a script or flow, in the order their Runtime tab lists them, +// for the at-a-glance badges of the editor top bar, the settings header and tabs, and the flow drawers. export function getActiveScriptSettingsBadges( - settings: ScriptAdvancedSettingsFields | undefined + settings: (ScriptAdvancedSettingsFields & FlowOnlyRuntimeFields) | undefined ): ScriptSettingsBadge[] { if (!settings) return [] const badges: ScriptSettingsBadge[] = [] + if (settings.skip_expr) { + badges.push({ + key: 'early_stop', + short: 'Stop if', + setting: 'early-stop', + label: 'Stop flow if', + icon: CircleStop, + detail: 'Stops early when the inputs match the expression' + }) + } + if (settings.early_return) { + badges.push({ + key: 'early_return', + short: 'Early return', + setting: 'early-return', + label: 'Early return', + icon: CornerDownLeft, + detail: `Sync webhooks return the result of step ${settings.early_return}` + }) + } // Non-positive concurrent_limit / timeout are treated as unset by the runtime // (legacy zero rows), so don't surface them as active settings. + if (settings.timeout != undefined && settings.timeout > 0) { + badges.push({ + key: 'timeout', + short: 'Timeout', + setting: 'timeout', + label: 'Timeout', + icon: Timer, + detail: `${settings.timeout}s` + }) + } if (settings.concurrent_limit != undefined && settings.concurrent_limit > 0) { badges.push({ key: 'concurrency', + short: 'Concurrency', + setting: 'concurrency', label: 'Concurrency', icon: Gauge, detail: `Max ${settings.concurrent_limit} execution${ @@ -66,49 +139,51 @@ export function getActiveScriptSettingsBadges( }` }) } + if (settings.debounce_delay_s != undefined && settings.debounce_delay_s > 0) { + badges.push({ + key: 'debounce', + short: 'Debounce', + setting: 'debounce', + label: 'Debounce', + icon: Combine, + detail: `Debounced by ${settings.debounce_delay_s}s` + }) + } if (settings.cache_ttl != undefined && settings.cache_ttl > 0) { badges.push({ key: 'cache', + short: 'Cache', + setting: 'cache', label: 'Cache', icon: Database, detail: `Cached for ${settings.cache_ttl}s` }) } - if (settings.timeout != undefined && settings.timeout > 0) { + if (settings.priority != undefined && settings.priority > 0) { badges.push({ - key: 'timeout', - label: 'Timeout', - icon: Timer, - detail: `${settings.timeout}s` - }) - } - if (settings.debounce_delay_s != undefined && settings.debounce_delay_s > 0) { - badges.push({ - key: 'debounce', - label: 'Debounce', - icon: Hourglass, - detail: `Debounced by ${settings.debounce_delay_s}s` + key: 'priority', + short: 'Priority', + setting: 'priority', + label: 'High priority', + icon: ChevronsUp, + detail: `Priority ${settings.priority}` }) } if (settings.restart_unless_cancelled) { badges.push({ key: 'perpetual', + short: 'Perpetual', + setting: 'perpetual', label: 'Perpetual', icon: Repeat, detail: 'Restarts unless cancelled' }) } - if (settings.dedicated_worker) { - badges.push({ - key: 'dedicated', - label: 'Dedicated', - icon: Cpu, - detail: 'Runs on dedicated workers' - }) - } if (settings.delete_after_secs != undefined) { badges.push({ key: 'delete_after_use', + short: 'Auto-delete', + setting: 'lifetime', label: 'Delete after use', icon: Trash2, detail: @@ -117,21 +192,78 @@ export function getActiveScriptSettingsBadges( : `Deleted ${settings.delete_after_secs}s after completion` }) } - if (settings.priority != undefined && settings.priority > 0) { - badges.push({ - key: 'priority', - label: 'High priority', - icon: ChevronsUp, - detail: `Priority ${settings.priority}` - }) - } if (settings.tag) { badges.push({ key: 'tag', + short: settings.tag, + setting: 'worker-group', label: settings.tag, icon: Tag, detail: `Worker tag: ${settings.tag}` }) } + if (settings.dedicated_worker) { + badges.push({ + key: 'dedicated', + short: 'Dedicated', + setting: 'dedicated-worker', + label: 'Dedicated', + icon: Cpu, + detail: 'Runs on dedicated workers' + }) + } + if (settings.same_worker) { + badges.push({ + key: 'same_worker', + short: 'Shared dir', + setting: 'same-worker', + label: 'Shared directory', + icon: FolderOpen, + detail: 'Steps run on the same worker and share ./shared' + }) + } + return badges +} + +// The active settings of a script's or flow's Permissions tab, in the order the tab lists them. +export function getActivePermissionBadges(settings: { + on_behalf_of_email?: string + job_token_scopes?: string[] | null + visible_to_runner_only?: boolean +}): ScriptSettingsBadge[] { + const badges: ScriptSettingsBadge[] = [] + if (settings.on_behalf_of_email) { + badges.push({ + key: 'on_behalf_of', + short: 'On behalf', + setting: 'on-behalf-of', + label: 'Run on behalf of', + icon: UserCog, + detail: settings.on_behalf_of_email + }) + } + if (settings.visible_to_runner_only) { + badges.push({ + key: 'invisible_runs', + short: 'Invisible', + setting: 'invisible-runs', + label: 'Invisible runs', + icon: EyeOff, + detail: 'Manual runs are visible only to whoever ran them' + }) + } + if (settings.job_token_scopes != null) { + badges.push({ + key: 'job_token', + short: 'Job token', + setting: 'job-token', + label: 'Restricted job token', + icon: KeyRound, + detail: + settings.job_token_scopes.length === 0 + ? 'No API access' + : settings.job_token_scopes.join(', ') + }) + } return badges }