refactor: harmonize flow and script settings layout (#12013)

* refactor: harmonize flow and script settings layout

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep the priority input open while it is edited

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep settings rows open while edited and name the deploying user for non-admins

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: write a setting before its row opens and show its real state while edited

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: slide settings rows open to their full height

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: remount a settings row's fields when its toggle turns it on

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: put the AI prompt behind a settings toggle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: summarize active runtime and permission settings on the metadata tab

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: move the active settings chips into the settings header

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: bring back the active setting icons on the settings tabs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Guilhem
2026-10-27 01:24:09 +01:00
committed by GitHub
co-authored by Claude Opus 5.5 Ruben Fiszel
parent 4a34dc69f5
commit 0249bb94ac
31 changed files with 1932 additions and 2008 deletions
+8 -2
View File
@@ -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));
}
@@ -1,19 +1,16 @@
<script lang="ts">
import Toggle from './Toggle.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ScopeSelector from './settings/ScopeSelector.svelte'
import SettingToggle from './itemSettings/SettingToggle.svelte'
interface Props {
/** `null`/`undefined`: the job token has the full permissions of the identity it runs as. */
value: string[] | null | undefined
kind: 'script' | 'flow' | 'step'
/** Match the toggles listed around it. */
size?: 'xs' | 'sm'
textClass?: string
}
let { value = $bindable(), kind, size = 'sm', textClass }: Props = $props()
let { value = $bindable(), kind }: Props = $props()
const OIDC_ONLY = ['oidc:write']
@@ -32,58 +29,48 @@
function setMode(next: Mode) {
customPicked = next === 'custom'
value =
next === 'oidc_only' ? [...OIDC_ONLY] : next === 'no_api' ? [] : [...(value ?? [])]
value = next === 'oidc_only' ? [...OIDC_ONLY] : next === 'no_api' ? [] : [...(value ?? [])]
}
</script>
<div class="flex flex-col gap-2">
<Toggle
{size}
{textClass}
checked={value != null}
on:change={() => {
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}
<ToggleButtonGroup selected={mode} onSelected={(v) => setMode(v)}>
{#snippet children({ item })}
<ToggleButton value="oidc_only" label="OIDC token only" {item} />
<ToggleButton value="no_api" label="No API access" {item} />
<ToggleButton value="custom" label="Custom" {item} />
{/snippet}
</ToggleButtonGroup>
{#if mode === 'custom'}
<ScopeSelector
bind:selectedScopes={() => value ?? [], (scopes) => (value = [...scopes])}
emptyLabel="No scopes selected. The job token can only reach its own job."
/>
{/if}
<p class="text-2xs text-secondary">
{#if kind === 'step'}
The <code>WM_TOKEN</code> 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 <code>WM_TOKEN</code> 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 <code>$var:</code>/<code>$res:</code> inputs, an AI agent's provider
resource, relative imports and object storage each need a read scope, and script state
(<code>getState</code>/<code>setState</code>) needs <code>resources:write</code>. Write scopes on scripts,
flows, schedules or triggers let a job escape the restriction. Jobs with a restricted token never
run on dedicated workers.
</p>
<SettingToggle
setting="job-token"
label="Restrict the job token"
tooltip={`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.`}
checked={value != null}
onchange={(on) => {
if (on) {
setMode('oidc_only')
} else {
value = null
}
}}
>
<ToggleButtonGroup selected={mode} onSelected={(v) => setMode(v)}>
{#snippet children({ item })}
<ToggleButton value="oidc_only" label="OIDC token only" {item} />
<ToggleButton value="no_api" label="No API access" {item} />
<ToggleButton value="custom" label="Custom" {item} />
{/snippet}
</ToggleButtonGroup>
{#if mode === 'custom'}
<ScopeSelector
bind:selectedScopes={() => value ?? [], (scopes) => (value = [...scopes])}
emptyLabel="No scopes selected. The job token can only reach its own job."
/>
{/if}
</div>
<p class="text-2xs text-secondary">
{#if kind === 'step'}
The <code>WM_TOKEN</code> 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 <code>WM_TOKEN</code> 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 <code>$var:</code>/<code>$res:</code> inputs, an AI agent's
provider resource, relative imports and object storage each need a read scope, and script state
(<code>getState</code>/<code>setState</code>) needs <code>resources:write</code>. Write scopes
on scripts, flows, schedules or triggers let a job escape the restriction. Jobs with a
restricted token never run on dedicated workers.
</p>
</SettingToggle>
@@ -1,283 +0,0 @@
<script lang="ts">
import { createBubbler } from 'svelte/legacy'
import Section from '$lib/components/Section.svelte'
import Label from '$lib/components/Label.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { SecondsInput } from '$lib/components/common'
import WorkerTagPicker from '$lib/components/WorkerTagPicker.svelte'
import DebounceLimit from '$lib/components/flows/DebounceLimit.svelte'
import { enterpriseLicense } from '$lib/stores'
import { isCloudHosted } from '$lib/cloud'
import type { Schema } from '$lib/common'
import type { ScriptAdvancedSettingsFields } from './scriptSettings'
const bubble = createBubbler()
interface Props {
script: ScriptAdvancedSettingsFields
// Workspace to read worker tags from (defaults to $workspaceStore inside the picker).
workspaceId?: string | undefined
}
let { script = $bindable(), workspaceId = undefined }: Props = $props()
</script>
<div class="flex flex-col gap-8">
<Section label="Worker group tag (queue)">
{#snippet header()}
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/worker_groups">
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.
</Tooltip>
{/snippet}
<WorkerTagPicker bind:tag={script.tag} {workspaceId} />
</Section>
<Section label="Concurrency limits" eeOnly>
{#snippet header()}
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/concurrency_limits">
Allowed concurrency within a given timeframe
</Tooltip>
{/snippet}
<Toggle
size="sm"
checked={Boolean(script.concurrent_limit)}
on:change={() => {
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)}
<div class="flex flex-col gap-4 mt-2">
<Label label="Max number of executions within the time window">
<div class="flex flex-row gap-2 max-w-sm whitespace-nowrap">
<input
disabled={!$enterpriseLicense}
bind:value={script.concurrent_limit}
type="number"
/>
</div>
</Label>
<Label label="Time window in seconds">
<SecondsInput
disabled={!$enterpriseLicense}
bind:seconds={script.concurrency_time_window_s}
/>
</Label>
<Label label="Custom concurrency key (optional)">
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/concurrency_limits#custom-concurrency-key"
>
Concurrency keys are global, you can have them be workspace specific using the
variable `$workspace`. You can also use an argument's value using `$args[name_of_arg]`</Tooltip
>
{/snippet}
<input
disabled={!$enterpriseLicense}
type="text"
bind:value={script.concurrency_key}
placeholder={`$workspace/script/${script.path ?? ''}-$args[foo]`}
/>
</Label>
</div>
{/if}
</Section>
<Section label="Cache">
{#snippet header()}
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/caching">
Cache the results for each possible inputs
</Tooltip>
{/snippet}
<div class="flex gap-2 shrink flex-col">
<Toggle
size="sm"
bind:checked={() => !!script.cache_ttl, (v) => (script.cache_ttl = v ? 300 : undefined)}
options={{ right: 'Cache the results for each possible inputs' }}
/>
{#if script.cache_ttl}
<div class="text-2xs text-secondary">How long to keep the cache valid</div>
<SecondsInput bind:seconds={script.cache_ttl} />
<Toggle
size="2xs"
bind:checked={
() => 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}
</div>
</Section>
<Section label="Timeout">
{#snippet header()}
<Tooltip documentationLink="https://www.windmill.dev/docs/script_editor/settings#timeout">
Add a custom timeout for this script
</Tooltip>
{/snippet}
<div class="flex gap-2 shrink flex-col">
<Toggle
size="sm"
checked={Boolean(script.timeout)}
on:change={() => {
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)}
<span class="text-xs font-semibold text-emphasis leading-none mt-2">Timeout duration</span>
<SecondsInput bind:seconds={script.timeout} />
{/if}
</div>
</Section>
<Section label="Debouncing">
{#snippet header()}
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/job_debouncing">
Debounce Jobs
</Tooltip>
{/snippet}
<DebounceLimit
size="sm"
bind:debounce_delay_s={script.debounce_delay_s}
bind:debounce_key={script.debounce_key}
bind:debounce_args_to_accumulate={script.debounce_args_to_accumulate}
bind:max_total_debouncing_time={script.max_total_debouncing_time}
bind:max_total_debounces_amount={script.max_total_debounces_amount}
schema={script.schema as Schema}
placeholder={`$workspace/script/${script.path ?? ''}-$args[foo]`}
/>
</Section>
<Section label="Perpetual script">
{#snippet header()}
<Tooltip documentationLink="https://www.windmill.dev/docs/script_editor/perpetual_scripts">
Restart the script upon ending unless cancelled
</Tooltip>
{/snippet}
<Toggle
size="sm"
checked={Boolean(script.restart_unless_cancelled)}
on:change={() => {
script.restart_unless_cancelled = script.restart_unless_cancelled ? undefined : true
}}
options={{ right: 'Restart upon ending unless cancelled' }}
/>
</Section>
<Section label="Dedicated workers" eeOnly>
{#snippet header()}
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/dedicated_workers">
In this mode, the script is meant to be run on dedicated workers that run the script at
native speed. Can reach &gt;1500rps per dedicated worker. Only available on enterprise
edition and for Python3, Deno, Bun and Bunnative.
</Tooltip>
{/snippet}
<Toggle
disabled={!$enterpriseLicense ||
isCloudHosted() ||
(script.language != 'bun' &&
script.language != 'bunnative' &&
script.language != 'python3' &&
script.language != 'deno')}
size="sm"
checked={Boolean(script.dedicated_worker)}
on:change={() => {
script.dedicated_worker = script.dedicated_worker ? undefined : true
}}
options={{ right: 'Script is run on dedicated workers' }}
/>
{#if script.dedicated_worker}
<div class="py-2">
<Alert type="info" title="Require dedicated workers">
A worker group needs to be configured to listen to this script. Select it in the dedicated
workers section of the worker group configuration.
</Alert>
</div>
{/if}
</Section>
<Section label="Delete after completion" eeOnly>
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/script_editor/settings#delete-after-use"
>
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.
</Tooltip>
{/snippet}
<div class="flex gap-2 shrink flex-col">
<Toggle
disabled={!$enterpriseLicense}
size="sm"
checked={script.delete_after_secs != null}
on:change={() => {
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}
<SecondsInput bind:seconds={script.delete_after_secs} disabled={!$enterpriseLicense} />
{/if}
</div>
</Section>
{#if !isCloudHosted()}
<Section label="High priority script" eeOnly>
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/jobs#high-priority-jobs"
>
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}
</Tooltip>
{/snippet}
<Toggle
disabled={!$enterpriseLicense || isCloudHosted()}
size="sm"
checked={script.priority !== undefined && script.priority > 0}
on:change={() => {
script.priority = script.priority ? undefined : 100
}}
options={{ right: 'Label as high priority' }}
>
{#snippet right()}
<input
type="number"
class="!w-16 ml-4"
disabled={script.priority === undefined}
bind:value={script.priority}
onfocus={bubble('focus')}
onchange={() => {
if (script.priority && script.priority > 100) {
script.priority = 100
} else if (script.priority && script.priority < 0) {
script.priority = 0
}
}}
/>
{/snippet}
</Toggle>
</Section>
{/if}
</div>
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
<script lang="ts">
import Label from '../Label.svelte'
import TextInput from '../text_input/TextInput.svelte'
import SettingToggle from '../itemSettings/SettingToggle.svelte'
import { copilotInfo } from '$lib/aiStore'
interface Props {
@@ -9,12 +9,22 @@
}
let { prompt = $bindable(), type = 'script' }: Props = $props()
// An empty prompt is stored as undefined, so the row keeps its own open state to show the field
// between turning the toggle on and typing.
let open = $state(false)
</script>
{#if !$copilotInfo.workspaceDisabled}
<Label
<SettingToggle
setting="ai-prompt"
label="AI prompt"
tooltip="An AI choosing this {type}'s inputs already reads its description and each field's description. Anything written here is given to it as extra guidance: mention specific fields and how they interact."
checked={open || !!prompt}
onchange={(on) => {
open = on
if (!on) prompt = undefined
}}
>
<TextInput
underlyingInputEl="textarea"
@@ -24,5 +34,5 @@
rows: 3
}}
/>
</Label>
</SettingToggle>
{/if}
@@ -1,80 +0,0 @@
<script lang="ts">
import Toggle from '$lib/components/Toggle.svelte'
import { FlowService, ScriptService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
kind: 'script' | 'flow'
scriptOrFlowPath: string
errorHandlerMuted: boolean | undefined
textDisabled?: boolean
color?: 'nord' | 'red' | 'blue' | undefined
}
let {
kind,
scriptOrFlowPath,
errorHandlerMuted = $bindable(),
textDisabled = false,
color = undefined
}: Props = $props()
let toggleState = $state(errorHandlerMuted)
async function toggleErrorHandler(): Promise<void> {
toggleState = !toggleState
if ($operatingWorkspace !== undefined) {
try {
if (kind === 'flow') {
await FlowService.toggleWorkspaceErrorHandlerForFlow({
workspace: $operatingWorkspace,
path: scriptOrFlowPath,
requestBody: {
muted: !errorHandlerMuted
}
})
} else {
await ScriptService.toggleWorkspaceErrorHandlerForScript({
workspace: $operatingWorkspace,
path: scriptOrFlowPath,
requestBody: {
muted: !errorHandlerMuted
}
})
}
} catch (error) {
sendUserToast(
`Error while toggling Workspace Error Handler: ${error.body || error.message}`,
true
)
toggleState = false
return
}
errorHandlerMuted = !errorHandlerMuted
toggleState = errorHandlerMuted
sendUserToast(
errorHandlerMuted ? 'Workspace error handler muted' : 'Workspace error handler active',
false
)
}
}
</script>
<Toggle
textClass="font-medium"
{color}
size="xs"
checked={toggleState}
on:change={toggleErrorHandler}
options={{
right: 'Mute',
rightTooltip: 'Disable workspace error handler, EE only',
rightDocumentationLink:
'https://www.windmill.dev/docs/core_concepts/error_handling#workspace-error-handler'
}}
{textDisabled}
/>
@@ -1,11 +1,12 @@
<script lang="ts">
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { SecondsInput } from '$lib/components/common'
import { enterpriseLicense } from '$lib/stores'
import Label from '../Label.svelte'
import type { Schema } from '$lib/common'
import { slideDynamic } from '$lib/transitions'
import Select from '$lib/components/select/Select.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import SettingToggle from '$lib/components/itemSettings/SettingToggle.svelte'
let {
debounce_delay_s = $bindable(),
@@ -14,11 +15,7 @@
max_total_debouncing_time = $bindable(),
max_total_debounces_amount = $bindable(),
schema = undefined,
placeholder,
size = 'xs',
color = undefined,
fontClass = 'font-normal',
indentContent = false
placeholder
}: {
debounce_delay_s: number | undefined
debounce_key: string | undefined
@@ -27,11 +24,6 @@
max_total_debounces_amount: number | undefined
schema?: Schema
placeholder: string
size: 'xs' | 'sm'
color?: 'nord' | undefined
fontClass?: string
/** Align the fields under the toggle's label, as the flow step settings do. */
indentContent?: boolean
} = $props()
// Check if an originalType like "string | string[]" is a top-level
@@ -90,89 +82,83 @@
}
})
// Presence, not truthiness: a single backspace takes the seeded 1s to 0 while the
// user is still typing, and reading that as off would disable the field mid-edit.
let off = $derived(!$enterpriseLicense || debounce_delay_s === undefined)
let disabled = $derived(!$enterpriseLicense)
</script>
<div>
<div class="flex flex-row items-center gap-2">
<Toggle
textClass={fontClass}
{color}
{size}
disabled={!$enterpriseLicense}
checked={debounce_delay_s !== undefined}
on:change={() => {
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}
<!-- Presence, not truthiness: a single backspace takes the seeded 1s to 0 while the user is
still typing, and reading that as off would close the fields mid-edit. -->
<SettingToggle
setting="debounce"
label="Debouncing"
tooltip="Consolidate multiple executions into a single run within a time window"
documentationLink="https://www.windmill.dev/docs/core_concepts/job_debouncing"
eeOnly
{disabled}
checked={debounce_delay_s !== undefined}
onchange={(on) => {
if (on) {
debounce_delay_s = 1
} else {
debounce_delay_s = undefined
debounce_key = undefined
}
}}
>
<Label label="Delay in seconds">
<SecondsInput {disabled} bind:seconds={debounce_delay_s} />
</Label>
<Label label="Custom debounce key (optional)">
{#snippet header()}
<Tooltip>
Debounce keys are global. Use `$workspace` to make one workspace specific, and
`$args[name_of_arg]` to use an argument's value.
</Tooltip>
{/snippet}
<TextInput bind:value={debounce_key} inputProps={{ type: 'text', disabled, placeholder }} />
</Label>
<Label label="Argument to accumulate (optional)">
{#snippet header()}
<Tooltip>
A list argument whose values are appended together across the debounced executions.
</Tooltip>
{/snippet}
<!-- A saved argument the schema no longer has stays listed, so it can still be cleared. -->
<Select
items={(selectedArg && !arrayArgs.includes(selectedArg)
? [...arrayArgs, selectedArg]
: arrayArgs
).map((arg) => ({ value: arg, label: arg }))}
bind:value={() => selectedArg || undefined, (v) => (selectedArg = v ?? '')}
placeholder="None"
clearable
disabled={disabled || (arrayArgs.length === 0 && !selectedArg)}
/>
</div>
{#if !off}
<div class="flex flex-col gap-4 mt-2 {indentContent ? 'pl-9' : ''}" transition:slideDynamic>
<Label label="Delay in seconds">
<SecondsInput disabled={off} bind:seconds={debounce_delay_s} />
</Label>
<Label label="Custom debounce key (optional)">
{#snippet header()}
<Tooltip>
Debounce keys are global, you can have them be workspace specific using the variable
`$workspace`. You can also use an argument's value using `$args[name_of_arg]`</Tooltip
>
{/snippet}
<input type="text" disabled={off} bind:value={debounce_key} {placeholder} />
</Label>
<Label label="Argument to accumulate (optional)">
{#snippet header()}
<Tooltip>
Select a list-type argument to accumulate across debounced executions. Values from each
debounced execution will be appended together.</Tooltip
>
{/snippet}
<select disabled={off} bind:value={selectedArg}>
<option value="">None</option>
{#each arrayArgs as arg}
<option value={arg}>{arg}</option>
{/each}
</select>
{#if arrayArgs.length === 0}
<div class="text-xs text-gray-500 mt-1">
No list-type arguments found in schema. Define arguments with type "array" to enable
accumulation.
</div>
{/if}
</Label>
<Label label="Max total debouncing time (optional)">
{#snippet header()}
<Tooltip>
Maximum total time (in seconds) that a job can be debounced before it must execute. Once
this time is reached, the job will run regardless of ongoing debouncing.</Tooltip
>
{/snippet}
<SecondsInput disabled={off} bind:seconds={max_total_debouncing_time} />
</Label>
<Label label="Max total debounces amount (optional)">
{#snippet header()}
<Tooltip>
Maximum number of times a job can be debounced before it must execute. Once this count
is reached, the job will run regardless of ongoing debouncing.</Tooltip
>
{/snippet}
<input type="number" disabled={off} bind:value={max_total_debounces_amount} min="0" />
</Label>
</div>
{/if}
</div>
{#if arrayArgs.length === 0}
<div class="text-2xs text-secondary">
No list argument in the schema. Add an argument of type "array" to accumulate it.
</div>
{/if}
</Label>
<Label label="Max total debouncing time (optional)">
{#snippet header()}
<Tooltip>
The longest a run can be held back by debouncing. Past it, the run starts whatever
debouncing is ongoing.
</Tooltip>
{/snippet}
<SecondsInput {disabled} bind:seconds={max_total_debouncing_time} />
</Label>
<Label label="Max total debounces amount (optional)">
{#snippet header()}
<Tooltip>
The most times a run can be debounced. Past it, the run starts whatever debouncing is
ongoing.
</Tooltip>
{/snippet}
<TextInput
class="w-24"
bind:value={max_total_debounces_amount}
inputProps={{ type: 'number', min: 0, disabled }}
/>
</Label>
</SettingToggle>
@@ -119,7 +119,7 @@
resolvedCount={resolvedModuleIds.length}
/>
{:else if selectedId?.startsWith('settings')}
<FlowSettings {enableAi} {noEditor} />
<FlowSettings {enableAi} {noEditor} {selectedId} {newFlow} />
{:else if selectedId === 'Input'}
<FlowInput
{noEditor}
@@ -55,7 +55,4 @@
bind:max_total_debounces_amount
schema={flowStateStore.val[selectedId]?.schema}
placeholder={`$workspace/flow/<flow_path>-${flowModule.id}`}
size="xs"
fontClass="text-xs font-normal text-primary"
indentContent
/>
@@ -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 @@
)
</script>
{#snippet sectionHeader(title: string)}
<div class="text-[11px] font-medium uppercase tracking-[0.04em] text-hint">
{title}
</div>
{/snippet}
<div
bind:this={rootEl}
class="flex flex-col gap-8 {embedded ? '' : 'flex-1 min-h-0 overflow-auto p-4 pb-8'}"
style={embedded ? undefined : 'scrollbar-gutter: stable'}
>
{#if !isFailure}
<section class="flex flex-col gap-3">
{@render sectionHeader('Flow control')}
<SettingGroup label="Flow control">
<div class="flex flex-col gap-6">
<div data-setting="skip">
<FlowModuleSkip bind:flowModule {parentModule} {previousModule} />
@@ -148,13 +141,11 @@
<div data-setting="sleep">
<FlowModuleSleep previousModuleId={previousModule?.id} bind:flowModule {isAgentTool} />
</div>
</div></section
</div></SettingGroup
>
{/if}
<section class="flex flex-col gap-3">
{@render sectionHeader('Execution policy')}
<SettingGroup label="Execution policy">
<div class="flex flex-col gap-6">
{#if !loopSubset}
<div data-setting="retries">
@@ -348,48 +339,42 @@
</div>
{/if}
<div data-setting="job-token">
<JobTokenScopesSetting
bind:value={flowModule.job_token_scopes}
kind="step"
size="xs"
textClass="text-xs font-normal text-primary"
/>
</div>
</div></section
<JobTokenScopesSetting bind:value={flowModule.job_token_scopes} kind="step" />
</div></SettingGroup
>
{#if s3Language && onApplyS3Snippet && !isFailure}
<section class="flex flex-col gap-3" data-setting="s3">
{@render sectionHeader('S3 snippets')}
<p class="text-xs text-tertiary">
Read and write S3 objects, and use Polars or DuckDB to run efficient ETL processes.
</p>
<div class="flex flex-row items-center justify-between gap-2">
<ToggleButtonGroup bind:selected={s3Kind} class="w-auto">
{#snippet children({ item })}
{#if s3Language === 'deno'}
<ToggleButton value="s3_client" small label="S3 lite client" {item} />
{:else}
<ToggleButton value="s3_client" small label="Boto3" {item} />
<ToggleButton value="polars" small label="Polars" {item} />
<ToggleButton value="duckdb" small label="DuckDB" {item} />
{/if}
{/snippet}
</ToggleButtonGroup>
<Button
size="xs"
variant="default"
on:click={() => s3Snippet && onApplyS3Snippet?.(s3Snippet)}
>
Apply snippet
</Button>
</div>
{#if s3Snippet}
<div class="overflow-auto max-h-64 border rounded-md">
<HighlightCode language={s3Language} code={s3Snippet} />
<div data-setting="s3">
<SettingGroup label="S3 snippets" class="flex flex-col gap-3">
<p class="text-xs text-tertiary">
Read and write S3 objects, and use Polars or DuckDB to run efficient ETL processes.
</p>
<div class="flex flex-row items-center justify-between gap-2">
<ToggleButtonGroup bind:selected={s3Kind} class="w-auto">
{#snippet children({ item })}
{#if s3Language === 'deno'}
<ToggleButton value="s3_client" small label="S3 lite client" {item} />
{:else}
<ToggleButton value="s3_client" small label="Boto3" {item} />
<ToggleButton value="polars" small label="Polars" {item} />
<ToggleButton value="duckdb" small label="DuckDB" {item} />
{/if}
{/snippet}
</ToggleButtonGroup>
<Button
unifiedSize="xs"
variant="default"
on:click={() => s3Snippet && onApplyS3Snippet?.(s3Snippet)}
>
Apply snippet
</Button>
</div>
{/if}
</section>
{#if s3Snippet}
<div class="overflow-auto max-h-64 border rounded-md">
<HighlightCode language={s3Language} code={s3Snippet} />
</div>
{/if}
</SettingGroup>
</div>
{/if}
</div>
File diff suppressed because it is too large Load Diff
@@ -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 @@
/>
<Drawer bind:this={settingsDrawer} size="600px">
<DrawerContent title="Script settings" on:close={() => settingsDrawer?.closeDrawer()}>
<DrawerContent title="Runtime settings" on:close={() => settingsDrawer?.closeDrawer()}>
{#if script}
<div class="flex flex-col gap-4">
<p class="text-xs text-secondary">
These runtime settings are saved together with the script when you press Save.
</p>
<ScriptAdvancedSettings {script} workspaceId={opWs} />
<ScriptRuntimeSettings {script} workspaceId={opWs} />
</div>
{/if}
{#snippet actions()}
<Button variant="border" on:click={() => settingsDrawer?.closeDrawer()}>Done</Button>
<Button variant="default" on:click={() => settingsDrawer?.closeDrawer()}>Done</Button>
{/snippet}
</DrawerContent>
</Drawer>
@@ -1,7 +1,7 @@
<script lang="ts">
import { Button, Drawer, DrawerContent } from '$lib/components/common'
import Alert from '$lib/components/common/alert/Alert.svelte'
import ScriptAdvancedSettings from '$lib/components/ScriptAdvancedSettings.svelte'
import ScriptRuntimeSettings from '$lib/components/itemSettings/ScriptRuntimeSettings.svelte'
import ScriptSettingsBadges from '$lib/components/ScriptSettingsBadges.svelte'
import { ScriptService, type Script } from '$lib/gen'
import { emptyString, sendUserToast } from '$lib/utils'
@@ -17,7 +17,7 @@
let drawer: Drawer | undefined = $state()
// cache_ignore_s3_path lives on NewScript but not the returned Script type;
// ScriptAdvancedSettings still edits it, so widen the local type to keep it.
// ScriptRuntimeSettings still edits it, so widen the local type to keep it.
let script: (Script & { cache_ignore_s3_path?: boolean }) | undefined = $state(undefined)
let loading = $state(false)
let loadError = $state<string | undefined>(undefined)
@@ -105,7 +105,7 @@
</script>
<Drawer bind:this={drawer} size="600px">
<DrawerContent title="Script settings" on:close={() => drawer?.closeDrawer()}>
<DrawerContent title="Runtime settings" on:close={() => drawer?.closeDrawer()}>
{#if loading}
<div class="center-center flex-col h-full text-tertiary">
<Loader2 class="animate-spin" size={16} />
@@ -118,8 +118,8 @@
</Alert>
{#if current}
<Button
size="xs"
variant="border"
unifiedSize="xs"
variant="default"
on:click={() => current && load(current.path, current.hash)}
>
Retry
@@ -136,7 +136,7 @@
Saving creates a new version of the workspace script with these runtime settings. The code
is left unchanged.
</p>
<ScriptAdvancedSettings {script} workspaceId={opWs} />
<ScriptRuntimeSettings {script} workspaceId={opWs} />
</div>
{/if}
{#snippet actions()}
@@ -0,0 +1,73 @@
<script module lang="ts">
/** Scrolls to a setting's row and flashes it, once its tab has rendered. */
export function revealSetting(root: HTMLElement | undefined, setting: string) {
const row = root?.querySelector<HTMLElement>(`[data-setting="${setting}"]`)
if (!row) return
row.scrollIntoView({ block: 'center', behavior: 'smooth' })
row.classList.add('setting-highlight')
setTimeout(() => row.classList.remove('setting-highlight'), 2000)
}
</script>
<script lang="ts">
import Badge from '$lib/components/common/badge/Badge.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import type { ScriptSettingsBadge } from '$lib/components/scriptSettings'
// The settings turned on in the Runtime and Permissions tabs, in the settings header so they
// show whichever tab is open.
interface Props {
groups: { tab: string; badges: ScriptSettingsBadge[] }[]
/** Opens `tab` on the row whose `data-setting` is `setting`. */
onopen: (tab: string, setting: string) => void
/** Chips shown before the rest collapse into a "+n" popover. */
max?: number
}
let { groups, onopen, max = 4 }: Props = $props()
let chips = $derived(groups.flatMap((g) => g.badges.map((badge) => ({ tab: g.tab, badge }))))
let shown = $derived(chips.slice(0, max))
let rest = $derived(chips.slice(max))
</script>
{#snippet chip({ tab, badge }: { tab: string; badge: ScriptSettingsBadge })}
<Badge
small
clickable
class="whitespace-nowrap"
icon={{ icon: badge.icon, position: 'left' }}
title="{badge.label}: {badge.detail}"
onclick={() => onopen(tab, badge.setting)}
>
{badge.short}
</Badge>
{/snippet}
{#if chips.length > 0}
<div class="flex flex-row flex-nowrap items-center gap-1">
{#each shown as c (c.badge.key)}
{@render chip(c)}
{/each}
{#if rest.length > 0}
<Popover
floatingConfig={{ strategy: 'absolute', placement: 'bottom-start' }}
contentClasses="border border-light rounded-lg shadow-lg p-2 surface-tertiary max-w-xs"
openOnHover
debounceDelay={150}
>
{#snippet trigger()}
<Badge small clickable>+{rest.length}</Badge>
{/snippet}
{#snippet content()}
<div class="flex flex-wrap gap-1">
{#each rest as c (c.badge.key)}
{@render chip(c)}
{/each}
</div>
{/snippet}
</Popover>
{/if}
</div>
{/if}
@@ -0,0 +1,38 @@
<script lang="ts">
import Label from '$lib/components/Label.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { SecondsInput } from '$lib/components/common'
import SettingToggle from './SettingToggle.svelte'
interface Props {
kind: 'flow' | 'script'
ttl: number | undefined
ignoreS3Path: boolean | undefined
}
let { kind, ttl = $bindable(), ignoreS3Path = $bindable() }: Props = $props()
</script>
<SettingToggle
setting="cache"
label="Cache results"
tooltip={`The result of the ${kind} is kept for each set of inputs, and a run with the same inputs returns it instead of running again.`}
documentationLink={kind === 'flow'
? 'https://www.windmill.dev/docs/flows/cache#cache-flows'
: 'https://www.windmill.dev/docs/core_concepts/caching'}
checked={ttl !== undefined && ttl > 0}
onchange={(on) => (ttl = on ? 300 : undefined)}
>
<Label label="How long to keep cache valid">
<SecondsInput bind:seconds={ttl} />
</Label>
<Toggle
size="2xs"
bind:checked={() => 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.'
}}
/>
</SettingToggle>
@@ -0,0 +1,71 @@
<script lang="ts">
import Label from '$lib/components/Label.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { SecondsInput } from '$lib/components/common'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { enterpriseLicense } from '$lib/stores'
import SettingToggle from './SettingToggle.svelte'
interface Props {
kind: 'flow' | 'script'
limit: number | undefined
timeWindowS: number | undefined
concurrencyKey: string | undefined
keyPlaceholder: string
}
let {
kind,
limit = $bindable(),
timeWindowS = $bindable(),
concurrencyKey = $bindable(),
keyPlaceholder
}: Props = $props()
let disabled = $derived(!$enterpriseLicense)
</script>
<SettingToggle
setting="concurrency"
label="Concurrency limit"
tooltip={`Caps how many runs of this ${kind} can start within a time window. The runs over the limit wait in the queue.`}
documentationLink={kind === 'flow'
? 'https://www.windmill.dev/docs/flows/concurrency_limit'
: 'https://www.windmill.dev/docs/core_concepts/concurrency_limits'}
eeOnly
{disabled}
checked={limit !== undefined && limit > 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
}
}
}}
>
<Label label="Max number of executions within the time window">
<TextInput class="w-24" bind:value={limit} inputProps={{ type: 'number', min: 1, disabled }} />
</Label>
<Label label="Time window in seconds">
<SecondsInput {disabled} bind:seconds={timeWindowS} />
</Label>
<Label label="Custom concurrency key (optional)">
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/concurrency_limits#custom-concurrency-key"
>
Concurrency keys are global. Use `$workspace` to make one workspace specific, and
`$args[name_of_arg]` to use an argument's value.
</Tooltip>
{/snippet}
<TextInput
bind:value={concurrencyKey}
inputProps={{ type: 'text', disabled, placeholder: keyPlaceholder }}
/>
</Label>
</SettingToggle>
@@ -0,0 +1,41 @@
<script lang="ts">
import Alert from '$lib/components/common/alert/Alert.svelte'
import { isCloudHosted } from '$lib/cloud'
import { enterpriseLicense } from '$lib/stores'
import SettingToggle from './SettingToggle.svelte'
interface Props {
kind: 'flow' | 'script'
dedicatedWorker: boolean | undefined
/** Why this item cannot run on dedicated workers, e.g. its language. */
unsupportedReason?: string
}
let { kind, dedicatedWorker = $bindable(), unsupportedReason }: Props = $props()
</script>
{#snippet reason()}
{unsupportedReason}
{/snippet}
<!-- Not available on the cloud, where the toggle could only ever be disabled. -->
{#if !isCloudHosted()}
<SettingToggle
setting="dedicated-worker"
label="Dedicated workers"
tooltip={kind === 'flow'
? 'The steps of the flow run on workers dedicated to it, which keep them warm so there is no cold start.'
: 'The script runs on workers dedicated to it, which keep it warm so there is no cold start. A dedicated worker can reach more than 1500 runs per second.'}
documentationLink="https://www.windmill.dev/docs/core_concepts/dedicated_workers"
eeOnly
disabled={!$enterpriseLicense || (!!unsupportedReason && !dedicatedWorker)}
checked={!!dedicatedWorker}
onchange={(on) => (dedicatedWorker = on || undefined)}
hint={unsupportedReason ? reason : undefined}
>
<Alert type="info" title="Requires a dedicated worker group" size="xs">
A worker group must listen to this {kind}. Select it in the dedicated workers section of the
worker group configuration.
</Alert>
</SettingToggle>
{/if}
@@ -0,0 +1,29 @@
<script lang="ts">
import { SecondsInput } from '$lib/components/common'
import { enterpriseLicense } from '$lib/stores'
import SettingToggle from './SettingToggle.svelte'
interface Props {
kind: 'flow' | 'script'
deleteAfterSecs: number | undefined
}
let { kind, deleteAfterSecs = $bindable() }: Props = $props()
</script>
<SettingToggle
setting="lifetime"
label="Delete after use"
tooltip={kind === 'flow'
? 'The logs, arguments and results of every step are deleted this long after the flow completes. 0 deletes them right away. The deletion is irreversible.'
: 'The logs, arguments and result of each run are deleted this long after it completes and returns its result. 0 deletes them right away. Only applies to synchronous webhooks and to the script run as a flow step. The deletion is irreversible.'}
documentationLink={kind === 'script'
? 'https://www.windmill.dev/docs/script_editor/settings#delete-after-use'
: undefined}
eeOnly
disabled={!$enterpriseLicense}
checked={deleteAfterSecs != null}
onchange={(on) => (deleteAfterSecs = on ? 0 : undefined)}
>
<SecondsInput bind:seconds={deleteAfterSecs} disabled={!$enterpriseLicense} />
</SettingToggle>
@@ -0,0 +1,19 @@
<script lang="ts">
import SettingToggle from './SettingToggle.svelte'
interface Props {
kind: 'flow' | 'script'
visibleToRunnerOnly: boolean | undefined
}
let { kind, visibleToRunnerOnly = $bindable() }: Props = $props()
</script>
<SettingToggle
setting="invisible-runs"
label="Make runs invisible to others"
tooltip={`Manual runs of this ${kind} are visible only to the user who ran them, owners included. Whoever runs it can override this from the advanced menu of the run form.`}
documentationLink="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs#invisible-runs"
checked={!!visibleToRunnerOnly}
onchange={(on) => (visibleToRunnerOnly = on || undefined)}
/>
@@ -0,0 +1,43 @@
<script lang="ts">
import { toggleWorkspaceErrorHandler } from '$lib/components/details/errorHandlerToggle'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { enterpriseLicense } from '$lib/stores'
import SettingToggle from './SettingToggle.svelte'
interface Props {
kind: 'flow' | 'script'
path: string
/** Also sent with the next deploy. */
muted: boolean | undefined
}
let { kind, path, muted = $bindable() }: Props = $props()
const operatingWorkspace = useOperatingWorkspace()
// The toggle shows the click until the request settles, then the muted state it left.
let pending: boolean | undefined = $state(undefined)
async function toggle() {
const next = await toggleWorkspaceErrorHandler($operatingWorkspace, kind, path, muted)
if (next !== undefined) muted = next
pending = undefined
}
</script>
{#snippet eeHint()}
Enterprise Edition only.
{/snippet}
<SettingToggle
setting="mute-error-handler"
label="Mute workspace error handler"
tooltip={`Failures of this ${kind} do not trigger the workspace error handler. Applies to the deployed ${kind} right away.`}
documentationLink="https://www.windmill.dev/docs/core_concepts/error_handling#workspace-error-handler"
checked={pending ?? !!muted}
onchange={(on) => {
pending = on
toggle()
}}
hint={$enterpriseLicense ? undefined : eeHint}
/>
@@ -0,0 +1,53 @@
<script lang="ts">
import Label from '$lib/components/Label.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { isCloudHosted } from '$lib/cloud'
import { enterpriseLicense } from '$lib/stores'
import SettingToggle from './SettingToggle.svelte'
interface Props {
kind: 'flow' | 'script'
priority: number | undefined
}
let { kind, priority = $bindable() }: Props = $props()
</script>
<!-- Not available on the cloud, where the toggle could only ever be disabled. -->
{#if !isCloudHosted()}
<SettingToggle
setting="priority"
label="High priority"
tooltip={kind === 'flow'
? 'The jobs of this flow and of its steps go ahead of the other jobs in the queue.'
: 'The jobs of this script go ahead of the other jobs in the queue.'}
documentationLink={kind === 'flow'
? 'https://www.windmill.dev/docs/flows/priority'
: 'https://www.windmill.dev/docs/core_concepts/jobs#high-priority-jobs'}
eeOnly
disabled={!$enterpriseLicense}
checked={priority !== undefined && priority > 0}
onchange={(on) => (priority = on ? 100 : undefined)}
>
<Label label="Priority number">
{#snippet header()}
<Tooltip>From 0 to 100. Higher numbers run first.</Tooltip>
{/snippet}
<TextInput
class="w-24"
bind:value={priority}
inputProps={{
type: 'number',
min: 0,
max: 100,
disabled: !$enterpriseLicense,
onchange: () => {
if (priority != undefined && priority > 100) priority = 100
else if (priority != undefined && priority < 0) priority = 0
}
}}
/>
</Label>
</SettingToggle>
{/if}
@@ -0,0 +1,131 @@
<script lang="ts">
import OnBehalfOfSelector, {
type OnBehalfOfChoice
} from '$lib/components/OnBehalfOfSelector.svelte'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
import { userStore } from '$lib/stores'
import { AlertTriangle } from 'lucide-svelte'
import SettingToggle from './SettingToggle.svelte'
import { canPreserveOnBehalfOf, deployedPermissionedAs, WM_DEPLOYERS_GROUP } from './onBehalfOf'
interface Props {
kind: 'flow' | 'script'
/** Email of the user whose access the runs use; undefined uses whoever starts each run. */
email: string | undefined
/** `u/username` of that user. */
permissionedAs: string | undefined
/** Sent as `preserve_on_behalf_of`: keep the picked user rather than the deployer. */
preserve: boolean
/** The selector's choice. Owned by the host, so it outlives this row's remounts: while it is
* undefined the selector picks the deployed user, which would override a later pick. */
choice: OnBehalfOfChoice
/** The deployed identity, which the "target" choice keeps. */
savedEmail: string | undefined
savedPermissionedAs: string | undefined
targetWorkspace: string
/** Never deployed, so no deployed identity can change. */
isNew?: boolean
}
let {
kind,
email = $bindable(),
permissionedAs = $bindable(),
preserve = $bindable(),
choice = $bindable(),
savedEmail,
savedPermissionedAs,
targetWorkspace,
isNew = false
}: Props = $props()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
let canPreserve = $derived(canPreserveOnBehalfOf(actingUser))
let myPermissionedAs = $derived($userStore?.username ? `u/${$userStore.username}` : undefined)
let enabled = $derived(!!email)
let deployPermissionedAs = $derived(
enabled ? deployedPermissionedAs(actingUser, permissionedAs) : undefined
)
// Whoever cannot keep another user's identity deploys the item as themselves.
let chosenUser = $derived(
(canPreserve ? (permissionedAs ?? email) : deployPermissionedAs) ?? 'the chosen user'
)
let willChange = $derived(
!isNew &&
!!savedPermissionedAs &&
!!deployPermissionedAs &&
deployPermissionedAs !== savedPermissionedAs
)
</script>
{#snippet valueHint()}
Lets people run this {kind} with resources and variables they can't access themselves.
{/snippet}
<SettingToggle
setting="on-behalf-of"
label="Run on behalf of"
tooltip={`Use it to share a ${kind} that touches secrets: people run it without ever seeing the credentials. Or pick a dedicated user, like billing-bot, so the ${kind} can only reach that user's folders and resources.`}
documentationLink="https://www.windmill.dev/docs/core_concepts/roles_and_permissions#run-on-behalf-of"
hint={valueHint}
checked={enabled}
onchange={(on) => {
if (on) {
email = $userStore?.email
permissionedAs = myPermissionedAs
} else {
email = undefined
permissionedAs = undefined
preserve = false
choice = undefined
}
}}
>
<div class="flex flex-wrap items-center gap-1.5">
<OnBehalfOfSelector
{targetWorkspace}
targetValue={savedEmail}
selected={choice}
onSelect={(c, details) => {
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}
<AlertTriangle size={14} class="text-yellow-500" />
<span class="text-2xs text-yellow-600 dark:text-yellow-400"
>Changes from <strong>{savedPermissionedAs}</strong> on deploy</span
>
{/if}
</div>
{#if !canPreserve}
<div class="text-2xs text-secondary">
Only workspace admins and members of {WM_DEPLOYERS_GROUP} can pick another user.
</div>
{/if}
<div class="text-2xs text-secondary">
Every run uses the access of {chosenUser}. Who started it is still recorded, and the code reads
their email from <code>WM_END_USER_EMAIL</code>.
</div>
</SettingToggle>
@@ -0,0 +1,140 @@
<script lang="ts">
import { Alert, Button, SecondsInput } from '$lib/components/common'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import DebounceLimit from '$lib/components/flows/DebounceLimit.svelte'
import { isCloudHosted } from '$lib/cloud'
import type { Schema } from '$lib/common'
import type { ScriptAdvancedSettingsFields } from '$lib/components/scriptSettings'
import { Plus, X } from 'lucide-svelte'
import SettingGroup from '$lib/components/itemSettings/SettingGroup.svelte'
import SettingToggle from './SettingToggle.svelte'
import WorkerTagSetting from './WorkerTagSetting.svelte'
import ConcurrencyLimitSetting from './ConcurrencyLimitSetting.svelte'
import CacheSetting from './CacheSetting.svelte'
import PrioritySetting from './PrioritySetting.svelte'
import DedicatedWorkersSetting from './DedicatedWorkersSetting.svelte'
import DeleteAfterUseSetting from './DeleteAfterUseSetting.svelte'
interface Props {
script: ScriptAdvancedSettingsFields
/** Workspace to read worker tags from; the picker defaults to the operating one. */
workspaceId?: string
tagPlaceholder?: string
/** Seeded when the custom timeout is turned on. */
defaultTimeout?: number
}
let { script = $bindable(), workspaceId, tagPlaceholder, defaultTimeout = 300 }: Props = $props()
const DEDICATED_WORKER_LANGUAGES = ['bun', 'bunnative', 'python3', 'deno']
// The list opens before a variable is typed, so the toggle cannot read only `envs`.
let addingEnvs = $state(false)
</script>
<div class="flex flex-col gap-8">
<SettingGroup label="Execution policy" class="flex flex-col gap-6">
<SettingToggle
setting="timeout"
label="Custom timeout"
tooltip="Used instead of the instance timeout, which it cannot exceed."
documentationLink="https://www.windmill.dev/docs/script_editor/settings#timeout"
checked={!!script.timeout}
onchange={(on) => (script.timeout = on ? defaultTimeout : undefined)}
>
<SecondsInput bind:seconds={script.timeout} />
</SettingToggle>
<ConcurrencyLimitSetting
kind="script"
bind:limit={script.concurrent_limit}
bind:timeWindowS={script.concurrency_time_window_s}
bind:concurrencyKey={script.concurrency_key}
keyPlaceholder={`$workspace/script/${script.path ?? ''}-$args[foo]`}
/>
<DebounceLimit
bind:debounce_delay_s={script.debounce_delay_s}
bind:debounce_key={script.debounce_key}
bind:debounce_args_to_accumulate={script.debounce_args_to_accumulate}
bind:max_total_debouncing_time={script.max_total_debouncing_time}
bind:max_total_debounces_amount={script.max_total_debounces_amount}
schema={script.schema as Schema}
placeholder={`$workspace/script/${script.path ?? ''}-$args[foo]`}
/>
<CacheSetting
kind="script"
bind:ttl={script.cache_ttl}
bind:ignoreS3Path={script.cache_ignore_s3_path}
/>
<PrioritySetting kind="script" bind:priority={script.priority} />
<SettingToggle
setting="perpetual"
label="Perpetual script"
tooltip="The script restarts as soon as it ends, unless it was cancelled."
documentationLink="https://www.windmill.dev/docs/script_editor/perpetual_scripts"
checked={!!script.restart_unless_cancelled}
onchange={(on) => (script.restart_unless_cancelled = on || undefined)}
/>
<DeleteAfterUseSetting kind="script" bind:deleteAfterSecs={script.delete_after_secs} />
</SettingGroup>
<SettingGroup label="Workers" class="flex flex-col gap-6">
<WorkerTagSetting
kind="script"
bind:tag={script.tag}
placeholder={tagPlaceholder}
{workspaceId}
/>
<DedicatedWorkersSetting
kind="script"
bind:dedicatedWorker={script.dedicated_worker}
unsupportedReason={script.language && !DEDICATED_WORKER_LANGUAGES.includes(script.language)
? 'Only for Python, Bun and Deno scripts. Other languages start no full runtime, so they already run about as fast.'
: undefined}
/>
{#if !isCloudHosted()}
<SettingToggle
setting="envs"
label="Custom env variables"
tooltip="Static environment variables passed to the deployed script."
documentationLink="https://www.windmill.dev/docs/script_editor/custom_environment_variables"
checked={addingEnvs || (script.envs?.length ?? 0) > 0}
onchange={(on) => {
addingEnvs = on
script.envs = on ? [''] : []
}}
>
<Alert type="info" title="Not passed in previews" size="xs">
Only the deployed script gets these variables.
</Alert>
{#each script.envs ?? [] as _, i (i)}
<div class="flex flex-row items-center gap-1 max-w-md">
{#if script.envs}
<TextInput
bind:value={script.envs[i]}
inputProps={{ type: 'text', placeholder: 'KEY=VALUE' }}
/>
{/if}
<Button
variant="subtle"
unifiedSize="md"
iconOnly
startIcon={{ icon: X }}
title="Remove variable"
onclick={() => (script.envs = script.envs?.filter((_, j) => j !== i))}
/>
</div>
{/each}
<div class="flex">
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: Plus }}
onclick={() => (script.envs = [...(script.envs ?? []), ''])}
>
Add variable
</Button>
</div>
</SettingToggle>
{/if}
</SettingGroup>
</div>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { Snippet } from 'svelte'
interface Props {
label: string
/** Applied to the wrapper of the group's settings. */
class?: string
children?: Snippet
}
let { label, class: clazz = '', children }: Props = $props()
</script>
<!-- A group of settings whose rows explain themselves: the label only names the group, so it stays
small and quiet instead of a section heading. -->
<section class="flex flex-col gap-3">
<div class="text-2xs font-medium uppercase tracking-wide text-secondary">{label}</div>
<div class={clazz}>
{@render children?.()}
</div>
</section>
@@ -0,0 +1,27 @@
<script lang="ts">
import Popover from '$lib/components/Popover.svelte'
import type { ScriptSettingsBadge } from '$lib/components/scriptSettings'
interface Props {
badges: ScriptSettingsBadge[]
}
let { badges }: Props = $props()
</script>
<!-- The configured settings of a tab, drawn on the tab the way the step "Run settings" tab
draws its own. -->
{#if badges.length > 0}
<div class="flex flex-row items-center gap-1 text-secondary">
{#each badges as badge (badge.key)}
{@const Icon = badge.icon}
<Popover notClickable>
<Icon size={12} aria-hidden="true" />
<span class="sr-only">{badge.label}: {badge.detail}</span>
{#snippet text()}
<span class="font-semibold">{badge.label}</span> · {badge.detail}
{/snippet}
</Popover>
{/each}
</div>
{/if}
@@ -0,0 +1,90 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { slideDynamic } from '$lib/transitions'
// One optional setting of a flow, script, app or step: a toggle, and the setting's
// parameters under it while it is on. The settings surfaces build their rows from this so
// they keep one spacing and indent.
interface Props {
label: string
tooltip?: string
documentationLink?: string
/** Whether the setting is configured; its parameters show while it is. */
checked: boolean | undefined
/** The new state, for the host to write or clear the setting's value. */
onchange?: (checked: boolean) => void
disabled?: boolean
/** Badges the toggle "EE" while it is disabled. */
eeOnly?: boolean
/** Lets a host scroll to the row: `[data-setting="<key>"]`. */
setting?: string
aiId?: string
aiDescription?: string
/** Shown under the row whatever its state, e.g. why it is disabled. */
hint?: Snippet
/** The setting's parameters. */
children?: Snippet
}
let {
label,
tooltip,
documentationLink,
checked,
onchange,
disabled = false,
eeOnly = false,
setting,
aiId,
aiDescription,
hint,
children
}: Props = $props()
// Hosts read `checked` from the value, and a value being typed passes through states that read
// as off (an emptied field, a 0, an empty expression). Once its parameters have focus, they
// stay open until the toggle is clicked; the toggle itself shows whether the value is set.
let editing = $state(false)
// Bumped each time the toggle turns the row on, so its parameters remount on the value the host
// just wrote even when the row was already open: an editor reads its code once, at mount.
let generation = $state(0)
</script>
<div class="flex flex-col gap-2" data-setting={setting}>
<Toggle
size="xs"
bind:checked={
() => 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}
<!-- pl-9 lines everything under the label: an xs toggle is 28px wide plus its 8px gap. -->
<div class="pl-9 text-2xs text-secondary">{@render hint()}</div>
{/if}
{#if (checked || editing) && children}
<!-- The slide clamps this box's height. A flex column would shrink an `overflow` child (an
editor) to fit the clamp, so the slide would measure it short and jump at the end. -->
<div transition:slideDynamic onfocusin={() => (editing = true)}>
{#key generation}
<div class="flex flex-col gap-2 pl-9">
{@render children()}
</div>
{/key}
</div>
{/if}
</div>
@@ -0,0 +1,57 @@
<script lang="ts">
import Toggle from '$lib/components/Toggle.svelte'
import WorkerTagPicker from '$lib/components/WorkerTagPicker.svelte'
import SettingToggle from './SettingToggle.svelte'
interface Props {
kind: 'flow' | 'script'
tag: string | undefined
/** Flows only: let steps that declare their own tag keep it. */
preserveStepTags?: boolean | undefined
placeholder?: string
workspaceId?: string
}
let {
kind,
tag = $bindable(),
preserveStepTags = $bindable(),
placeholder,
workspaceId
}: Props = $props()
// The picker opens before a tag is picked, so the toggle cannot read only `tag`.
let picking = $state(false)
let checked = $derived(picking || !!tag)
</script>
<SettingToggle
setting="worker-group"
label="Worker group tag"
tooltip={kind === 'flow'
? "Every step of the flow runs on the workers listening to this tag (queue), whatever the step's own tag. Without one, the flow's control steps run with the default 'flow' tag and each step with its own."
: 'The script runs on the workers listening to this tag (queue), for instance a highmem or gpu worker group.'}
documentationLink="https://www.windmill.dev/docs/core_concepts/worker_groups"
{checked}
onchange={(on) => {
picking = on
if (!on) {
tag = undefined
preserveStepTags = undefined
}
}}
>
<WorkerTagPicker bind:tag {placeholder} {workspaceId} />
{#if kind === 'flow' && tag}
<Toggle
size="2xs"
bind:checked={preserveStepTags}
options={{
right: 'Preserve step worker tags',
rightTooltip:
'Steps that declare their own worker tag run on it instead of the flow tag. Steps without one still inherit the flow tag.',
rightDocumentationLink: 'https://www.windmill.dev/docs/core_concepts/worker_groups'
}}
/>
{/if}
</SettingToggle>
@@ -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)
})
})
@@ -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
}
@@ -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', () => {
+176 -44
View File
@@ -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
}