mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-22 08:02:40 +00:00
* feat: cap user token expiration with an instance setting Adds `max_token_expiration_days`, an instance-wide ceiling on how far ahead a token created through `POST /users/tokens/create` may expire. With it set, that route refuses a token with no expiration and one that expires past the window; absent or non-positive, nothing changes. Only the user-facing handler enforces it. Server-side mints (native trigger webhook tokens, app embed tokens, sessions) pick a lifetime the caller never chooses and go straight to `create_token_internal`, so they stay uncapped, as does the superadmin `impersonate` route. Service accounts are exempt, in the workspace the token targets or in any workspace for a global token, so unattended automation can keep longer-lived credentials. The token form now surfaces the API error instead of only logging it, and offers "Expires In" in MCP mode as well: that mode always sent no expiration, which the cap refuses, leaving MCP URLs impossible to generate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: shorten over-long token expirations instead of refusing them Refusing a non-compliant request breaks the callers that cannot comply. The CLI authorization page, `wmill user create-token` and the editor's language-server token each pick a lifetime — usually none at all — with no way to read the setting, so a cap made browser login hang and the editor lose its LSP root rather than stopping the long-lived tokens the setting is aimed at. `cap_token_expiration` now returns the expiration to store, shortening a request that asks for too long or for none. The policy still holds absolutely, no caller can break, and there is no clock-skew boundary where an expiration exactly at the ceiling flips to an error. The token form needed no changes at all, so its MCP and error-toast edits are gone with it. Also drops the Enterprise badge on the setting, which nothing enforced, notes the mint paths in docs/auth-surface.md, and pins that `tokens/impersonate` and the second-workspace case stay outside the exemption. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: drop the unintended token-form change and correct the exemption docs The token form needed no change once the ceiling shortens rather than refuses, but the earlier revert restored from the index, which already held the staged edit, so the MCP expiration field and the error toast stayed on the branch with a comment justifying them by a refusal that no longer happens. docs/auth-surface.md claimed a service-account row in any workspace exempts outright; that only holds for a workspace-less token, which has no workspace to match. A ceiling written as a string, which the YAML instance config and config sync can both produce, now has a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: offer only expirations within the ceiling in the token form The server shortens a token that asks for longer than `max_token_expiration_days` or for no expiration, which the token form could not tell anyone: a user picking "No expiration" got the ceiling silently. The form now reads the setting and, with one set, drops "No expiration" and every choice above it, adds the ceiling itself as "N days (maximum)" and selects it, and says the instance limits tokens to N days. MCP mode hides the expiration field and always sent none, so with a ceiling the field now shows there too and keeps its value across the toggle. Without a ceiling the form is unchanged. Reading it needs no superadmin: the setting joins the keys any logged-in user can read through `GET /settings/global/{key}`. It holds a policy, not a secret. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: make the token form and the server agree on what counts as a ceiling The form parsed `max_token_expiration_days` more loosely than `cap_token_expiration`, so the two could disagree on whether a ceiling exists at all. A `7.0` from the YAML instance config, or a string such as "7.0" or "1e1", made the form hide "No expiration" and announce a 7-day limit while the server capped nothing; a value between chrono's and JavaScript's date limits preselected an expiration the server could not parse. Both now read the same thing as a ceiling: a whole number of days from 1 to 1,000,000, stored as an integer, an integral float or a string of digits. `parseMaxTokenExpirationDays` holds the frontend's copy, and the instance settings validation uses it too, so the settings page no longer accepts a value the server would ignore. The bound replaces the date-range guard on both sides. Also corrects the rationale for shortening rather than refusing: the setting is now readable by any logged-in user, so those callers do not read it rather than cannot, and CLIs already installed never will. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: cap service-account tokens like everyone else's The ticket exempted service accounts from `max_token_expiration_days`, but their tokens are the long-lived ones a rotation policy is meant to bound, and the exemption let any workspace admin get an uncapped token by impersonating one. It also left the token form unable to agree with the server: an admin impersonating a service account was offered only capped choices while the server would have kept any. `cap_token_expiration` now takes just the requested expiration, with no per-caller lookup, and the service-account query and its cache entry are gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: cap superadmin impersonation tokens and pin the frontend parser `POST /users/tokens/impersonate` wrote its own token row with whatever expiration the superadmin sent, so it was the one route left that could mint a token that never expires with `max_token_expiration_days` set. The ceiling only decides the stored expiration (the auth lookup never reads the setting), so leaving it uncapped meant exactly that. It now goes through `cap_token_expiration` like `create_token`; nothing in Windmill calls it, so no caller changes. Also adds `tokenExpiration.test.ts`, pinning which stored values `parseMaxTokenExpirationDays` reads as a ceiling against the server's reading, and documents that tokens existing when the setting is turned on or lowered keep their expiration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: reject a max_token_expiration_days the token routes cannot read The settings API and config sync stored any value for the key, and the token routes can only read an unparseable one as no ceiling. A typo such as `7.5` or "7.0" was accepted and silently turned the policy off. `parse_max_token_expiration_days` in windmill-common is now the single server reading of the setting: null or empty clears it, a whole number of days within the bound is the ceiling, anything else is an error. The settings write hook and `sync_global_settings_declarative` reject that error, and `cap_token_expiration` reads through the same function, logging a value written around both. Tests: the parser's accept/clear/reject table (the same table as the frontend parser's), the settings API refusing 7.5, and config sync refusing "7.0". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: reserve the CLI login token label so its expiry does not email the user With a token expiration ceiling, the token the CLI authorization page mints now expires, so every `wmill` login earned an "expiring soon" and an "expired and deleted" email and critical alert. The CLI already signs in again on its own when that token stops working, so those notifications ask the user to do nothing. The page now labels it `cli-login:<username>` (previously `cli-<username>`), reserved in `is_user_token` and its SQL and Svelte mirrors: no expiry notifications, and the label cannot be edited. A colon-terminated namespace like `embed_app:` and `impersonation:` keeps hand-made labels clear of it. Not in `is_server_minted_label`, since the page mints through `/users/tokens/create`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: skip the expiring-soon warning for tokens that were short-lived from the start A token whose whole lifetime fits in the 7-day warning window got its "expiring soon" email (and critical alert, when enabled) minutes after it was created, about a lifetime its creator had just picked. With an expiration ceiling of 7 days or less that is every token created from the form or `wmill token create`. `register_token_expiry_notification` no longer queues a warning for such a token. The window is now `TOKEN_EXPIRY_WARNING_DAYS`, shared with `check_expiring_tokens`, so shortening the warning window can never leave tokens of an intermediate lifetime with no warning at all. The "expired and deleted" notice still goes out for every user token. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: exempt service-account tokens from the expiration ceiling again Service accounts are the identity automation that needs a long-lived credential runs as, so their tokens are exempt from `max_token_expiration_days` once more: a service account in the workspace the token names, or in any workspace for a workspace-less token. `tokens/impersonate` checks the impersonated account, so a superadmin minting a token for a service account gets the same exemption. The token form applies the same rule for the account it is running as, when that account is a service account in the current workspace, which is what an admin impersonating one sees; otherwise it would offer only capped choices while the server keeps any. Any workspace admin can create and impersonate a service account to hold an uncapped token, so the ceiling bounds personal tokens; the doc comment and docs/auth-surface.md say so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: decide the token form's service-account exemption from the token's workspace The form treated the account as a service account only when it was one in the workspace the app was on, while the server checks the workspace the token is for, or any workspace for a workspace-less token. With an email that is a service account in one workspace and an ordinary member of another, picking the other workspace in MCP mode offered "No expiration" and the server silently stored the ceiling; the reverse hid the exemption. `GET /workspaces/users` now returns each membership's `is_service_account` (its query already joins the `usr` row), and the form applies the server's rule to the token's own workspace. The selection becomes a derived value held within the ceiling, so switching to a capped workspace never leaves an unoffered choice selected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1520 lines
53 KiB
TypeScript
1520 lines
53 KiB
TypeScript
import type { ButtonType } from './common/button/model'
|
||
import { allowedOriginsSettingError } from './triggers/http/utils'
|
||
import { z } from 'zod'
|
||
import { instanceBannerFormError } from './instanceBanner'
|
||
import { parseMaxTokenExpirationDays } from '$lib/tokenExpiration'
|
||
import { writable } from 'svelte/store'
|
||
|
||
/**
|
||
* Bumped after instance settings are successfully saved. Settings whose display
|
||
* depends on server-side state derived from a saved value (rather than on the value
|
||
* in the form) subscribe to this to refetch — the form values change on every
|
||
* keystroke, so they are not a usable signal for that.
|
||
*/
|
||
export const instanceSettingsSaved = writable(0)
|
||
|
||
// Languages that support HTTP request tracing via OTEL proxy
|
||
export const OTEL_TRACING_PROXY_LANGUAGES = [
|
||
'nativets',
|
||
'python3',
|
||
'deno',
|
||
'bun',
|
||
'go',
|
||
'bash',
|
||
'rust',
|
||
'csharp',
|
||
'nu',
|
||
'ruby'
|
||
] as const
|
||
|
||
export interface Setting {
|
||
label: string
|
||
description?: string
|
||
placeholder?: string
|
||
cloudonly?: boolean
|
||
ee_only?: string
|
||
/** Ceiling a `seconds` field enforces on a build without a license, when CE genuinely caps
|
||
* the value. Not implied by `ee_only`: a setting can be EE-badged because the feature it
|
||
* configures is EE while the value itself has the same range on either edition. */
|
||
ceMaxSeconds?: number
|
||
tooltip?: string
|
||
key: string
|
||
// If value is not specified for first element, it will automatcally use undefined
|
||
select_items?: {
|
||
label: string
|
||
tooltip?: string
|
||
// If not specified, label will be used
|
||
value?: string
|
||
}[]
|
||
fieldType:
|
||
| 'text'
|
||
| 'number'
|
||
| 'boolean'
|
||
| 'password'
|
||
| 'select'
|
||
| 'select_python'
|
||
| 'textarea'
|
||
| 'codearea'
|
||
| 'seconds'
|
||
| 'email'
|
||
| 'license_key'
|
||
| 'object_store_config'
|
||
| 'critical_error_channels'
|
||
| 'critical_alerts_on_db_oversize'
|
||
| 'slack_connect'
|
||
| 'smtp_connect'
|
||
| 'indexer_rates'
|
||
| 'otel'
|
||
| 'otel_tracing_proxy'
|
||
| 'secret_backend'
|
||
| 'github_enterprise_app'
|
||
| 'webhook_base_url'
|
||
| 'ws_connectivity'
|
||
| 'retention_overrides'
|
||
| 'instance_banner'
|
||
storage: SettingStorage
|
||
advancedToggle?: {
|
||
label: string
|
||
onChange: (values: Record<string, any>) => Record<string, any>
|
||
checked: (values: Record<string, any>) => boolean
|
||
}
|
||
hiddenIfNull?: boolean
|
||
hiddenIfEmpty?: boolean
|
||
hiddenInEe?: boolean
|
||
hideInQuickSetup?: boolean
|
||
requiresReloadOnChange?: boolean
|
||
triggersRestart?: boolean
|
||
isValid?: (value: any) => boolean
|
||
validate?: (value: any) => Record<string, string>
|
||
error?: string
|
||
defaultValue?: () => any
|
||
codeAreaLang?: string
|
||
actionButton?: {
|
||
label: string
|
||
onclick: (values: Record<string, any>) => Promise<void>
|
||
variant?: ButtonType.Variant
|
||
}
|
||
}
|
||
|
||
export type SettingStorage = 'setting'
|
||
|
||
const positiveNumber = z.number().positive('Must be a positive number')
|
||
const nonNegativeNumber = z.number().nonnegative('Must be zero or a positive number')
|
||
|
||
const indexerSettingsSchema = z
|
||
.object({
|
||
writer_memory_budget: positiveNumber.optional(),
|
||
commit_job_max_batch_size: positiveNumber.optional(),
|
||
refresh_index_period: positiveNumber.optional(),
|
||
max_indexed_job_log_size: positiveNumber.optional(),
|
||
commit_log_max_batch_size: positiveNumber.optional(),
|
||
refresh_log_index_period: positiveNumber.optional(),
|
||
max_index_time_window_secs: nonNegativeNumber.optional()
|
||
})
|
||
.passthrough()
|
||
|
||
function validateIndexerSettings(v: any): Record<string, string> {
|
||
if (!v) return {}
|
||
const result = indexerSettingsSchema.safeParse(v)
|
||
if (result.success) return {}
|
||
const errors: Record<string, string> = {}
|
||
for (const issue of result.error.issues) {
|
||
const field = issue.path[0]?.toString()
|
||
if (field) errors[field] = issue.message
|
||
}
|
||
return errors
|
||
}
|
||
|
||
export const scimSamlSetting: Setting[] = [
|
||
{
|
||
label: 'SCIM token',
|
||
description: 'Token used to authenticate requests from the IdP',
|
||
key: 'scim_token',
|
||
fieldType: 'password',
|
||
placeholder: 'mytoken',
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
},
|
||
{
|
||
label: 'SAML metadata',
|
||
description: 'XML metadata url OR content for the SAML IdP',
|
||
key: 'saml_metadata',
|
||
fieldType: 'textarea',
|
||
placeholder: 'https://dev-2578259.okta.com/app/exkaell8gidiiUWrg5d7/sso/saml/metadata ',
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
triggersRestart: true
|
||
}
|
||
]
|
||
|
||
/**
|
||
* Mirror of `validate_webhook_base_url` in backend/windmill-common/src/global_settings.rs.
|
||
* Parses rather than pattern-matches so the two agree on the awkward cases (an
|
||
* invalid port like `https://x:abc`, IPv6 hosts, surrounding whitespace) — the
|
||
* server trims and runs `Url::parse`, so this does the same. The webhook path is
|
||
* appended to this value verbatim, hence no query, fragment or trailing slash.
|
||
*/
|
||
export function isValidWebhookBaseUrl(value: unknown): boolean {
|
||
if (value == undefined) return true
|
||
// `Setting.isValid` receives `any`, and YAML mode can put any JSON type here — a
|
||
// non-string must read as invalid rather than throw while the form computes which
|
||
// categories are in error.
|
||
if (typeof value !== 'string') return false
|
||
if (value.trim() === '') return true
|
||
const trimmed = value.trim()
|
||
let url: URL
|
||
try {
|
||
url = new URL(trimmed)
|
||
} catch {
|
||
return false
|
||
}
|
||
return (
|
||
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
||
url.host !== '' &&
|
||
// Userinfo would end up in the per-repository receiver stored in workspace
|
||
// settings, which workspace admins can read.
|
||
url.username === '' &&
|
||
url.password === '' &&
|
||
// Tested on the raw string, not `url.search`/`url.hash`: those are `''` for a
|
||
// bare `?` or `#`, while Rust reports an empty-but-present query/fragment and
|
||
// rejects it. A literal delimiter is never valid here either way.
|
||
!trimmed.includes('?') &&
|
||
!trimmed.includes('#') &&
|
||
// `new URL` silently percent-encodes a space in the path, where the server
|
||
// rejects it outright.
|
||
!/\s/.test(trimmed) &&
|
||
!trimmed.endsWith('/')
|
||
)
|
||
}
|
||
|
||
export const settings: Record<string, Setting[]> = {
|
||
Core: [
|
||
{
|
||
label: 'Base url',
|
||
description:
|
||
'Public base url of the instance. <a href="https://www.windmill.dev/docs/advanced/instance_settings#global-users">Learn more</a>',
|
||
key: 'base_url',
|
||
fieldType: 'text',
|
||
placeholder: 'https://windmill.com',
|
||
storage: 'setting',
|
||
error: 'Base url must start with http:// or https:// and not end with / or a space',
|
||
isValid: (value: string | undefined) =>
|
||
value == undefined ||
|
||
(value?.startsWith('http') &&
|
||
value.includes('://') &&
|
||
!value?.endsWith('/') &&
|
||
!value?.endsWith(' '))
|
||
},
|
||
{
|
||
label: 'Email domain',
|
||
description: 'Domain to display in webhooks for email triggers (should match the MX record)',
|
||
key: 'email_domain',
|
||
fieldType: 'text',
|
||
placeholder: 'mail.windmill.com',
|
||
storage: 'setting',
|
||
triggersRestart: true,
|
||
error: 'Must be a valid domain',
|
||
isValid: (value: string | undefined) =>
|
||
value == undefined ||
|
||
value === '' ||
|
||
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/.test(
|
||
value
|
||
)
|
||
},
|
||
{
|
||
label: 'Request size limit in MB',
|
||
description: 'Maximum size of HTTP requests in MB.',
|
||
cloudonly: true,
|
||
key: 'request_size_limit_mb',
|
||
fieldType: 'number',
|
||
placeholder: '50',
|
||
storage: 'setting',
|
||
triggersRestart: true
|
||
},
|
||
{
|
||
label: 'License key',
|
||
description:
|
||
'License key required to use the EE (switch image for windmill-ee). <a href="https://www.windmill.dev/docs/advanced/instance_settings#license-key">Learn more</a>',
|
||
key: 'license_key',
|
||
fieldType: 'license_key',
|
||
placeholder: 'only for EE',
|
||
storage: 'setting'
|
||
},
|
||
{
|
||
label: 'Announcement banner',
|
||
description:
|
||
'Message shown above every page of the instance, for maintenance windows and incidents.',
|
||
key: 'instance_banner',
|
||
fieldType: 'instance_banner',
|
||
storage: 'setting',
|
||
// The banner only renders on the managed cloud, so only offer it there.
|
||
cloudonly: true,
|
||
hideInQuickSetup: true,
|
||
// Gates Save. The card renders the specific message itself, so no `error` here.
|
||
isValid: (value: any) => instanceBannerFormError(value) == undefined
|
||
},
|
||
{
|
||
label: 'Non-prod instance',
|
||
description:
|
||
'Whether we should consider the reported usage of this instance as non-prod. <a href="https://www.windmill.dev/docs/advanced/instance_settings#non-prod-instance">Learn more</a>',
|
||
key: 'dev_instance',
|
||
fieldType: 'boolean',
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
hideInQuickSetup: true
|
||
},
|
||
{
|
||
label: 'App workspace prefix',
|
||
description:
|
||
'When enabled apps will be accessible at /a/{workspace_id}/{custom_path} instead of /a/{custom_path} allowing you to define same custom path for apps in different workspace without conflict',
|
||
key: 'app_workspaced_route',
|
||
fieldType: 'boolean',
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
hideInQuickSetup: true
|
||
},
|
||
{
|
||
label: 'HTTP route workspace prefix',
|
||
description:
|
||
'When enabled HTTP routes will be accessible at /api/r/{workspace_id}/{route} instead of /api/r/{route} allowing you to define same route path in different workspaces without conflict',
|
||
key: 'http_route_workspaced_route',
|
||
fieldType: 'boolean',
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
hideInQuickSetup: true
|
||
},
|
||
{
|
||
label: 'HTTP route default allowed origins',
|
||
description:
|
||
'Origins that HTTP routes allow to call them from a browser when the route sets none of its own. A route overrides this with its own list, and opts out entirely by setting its allowed origins to *. Leave unset for no instance-wide default, so every route is callable from any origin unless it restricts itself.',
|
||
key: 'http_route_default_allowed_origins',
|
||
fieldType: 'text',
|
||
placeholder: 'https://app.example.com, https://admin.example.com',
|
||
storage: 'setting',
|
||
error:
|
||
'Each origin must be visible ASCII with no comma, and there can be at most 100 of them. null is not allowed, since every sandboxed iframe sends it.',
|
||
// The same check the API applies, so a value it would refuse cannot be
|
||
// saved here and then silently drop to no restriction at the next boot.
|
||
isValid: (value: unknown) => allowedOriginsSettingError(value) === undefined,
|
||
ee_only: '',
|
||
hideInQuickSetup: true
|
||
},
|
||
{
|
||
label: 'Audit log retention (days)',
|
||
key: 'audit_log_retention_days',
|
||
description: 'How long to keep audit log entries in the database. Default: 365 days.',
|
||
fieldType: 'number',
|
||
placeholder: '365',
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
hideInQuickSetup: true
|
||
},
|
||
{
|
||
label: 'Maximum token expiration (days)',
|
||
key: 'max_token_expiration_days',
|
||
description:
|
||
'Furthest ahead an API token a user creates can expire, in days. A token asking for longer, or for no expiration, is created with this expiration instead. Service accounts are exempt, so automation can keep longer-lived credentials. Leave empty to let users pick any expiration, including none.',
|
||
fieldType: 'number',
|
||
placeholder: 'no limit',
|
||
storage: 'setting',
|
||
hideInQuickSetup: true,
|
||
error: 'Must be a whole number of days, from 1 to 1,000,000',
|
||
// The server reads anything else as no ceiling at all.
|
||
isValid: (value: unknown) =>
|
||
value === undefined ||
|
||
value === null ||
|
||
value === '' ||
|
||
parseMaxTokenExpirationDays(value) !== undefined
|
||
}
|
||
],
|
||
Jobs: [
|
||
{
|
||
label: 'Retention period in secs',
|
||
key: 'retention_period_secs',
|
||
description:
|
||
'How long to keep the jobs data in the database (max 30 days on CE). <a href="https://www.windmill.dev/docs/advanced/instance_settings#retention-period-in-secs">Learn more</a>',
|
||
fieldType: 'seconds',
|
||
placeholder: '30',
|
||
storage: 'setting',
|
||
ee_only: 'You can only adjust this setting to above 30 days in the EE version',
|
||
// Mirrors CE_MAX_RETENTION_PERIOD_SECS, which the backend clamps to on write.
|
||
ceMaxSeconds: 60 * 60 * 24 * 30,
|
||
cloudonly: false
|
||
},
|
||
{
|
||
label: 'Per-workspace retention overrides',
|
||
key: 'retention_period_secs_overrides',
|
||
description:
|
||
'Override the job retention period for specific workspaces, independently of the instance-wide value above (longer or shorter). Jobs in a workspace without an override follow the instance-wide setting.',
|
||
fieldType: 'retention_overrides',
|
||
storage: 'setting',
|
||
ee_only: 'Per-workspace retention overrides are only available in the EE version',
|
||
cloudonly: false
|
||
},
|
||
{
|
||
label: 'Job isolation',
|
||
key: 'job_isolation',
|
||
fieldType: 'select',
|
||
description:
|
||
'Isolation mode for job execution. None: no isolation. Unshare: PID namespace isolation via unshare. Nsjail: full nsjail sandboxing. <a href="https://www.windmill.dev/docs/advanced/security_isolation">Learn more</a>',
|
||
storage: 'setting',
|
||
select_items: [
|
||
{
|
||
label: 'None',
|
||
value: 'none'
|
||
},
|
||
{
|
||
label: 'Unshare',
|
||
value: 'unshare'
|
||
},
|
||
{
|
||
label: 'Nsjail',
|
||
value: 'nsjail_sandboxing'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
label: 'Nsjail /tmp backing',
|
||
key: 'nsjail_tmp_backing',
|
||
fieldType: 'select',
|
||
description:
|
||
'How <code>/tmp</code> is backed inside the nsjail sandbox. <strong>RAM (tmpfs)</strong> is the default — fast, with a hard size cap from <em>Nsjail tmpfs size</em>, but consumes worker memory. <strong>Disk (bind mount)</strong> uses a per-job directory on the worker disk — no RAM cost, but the only remaining per-file ceiling is <code>rlimit_fsize</code> (~1GB for python/ansible, unbounded for most other languages because they set <code>disable_rl: true</code>); pair with host disk monitoring or quotas.',
|
||
storage: 'setting',
|
||
placeholder: 'tmpfs',
|
||
defaultValue: () => 'tmpfs',
|
||
select_items: [
|
||
{ label: 'RAM (tmpfs) — default', value: 'tmpfs' },
|
||
{ label: 'Disk (bind mount)', value: 'disk' }
|
||
]
|
||
},
|
||
{
|
||
label: 'Nsjail tmpfs size (MB)',
|
||
key: 'nsjail_tmpfs_size_mb',
|
||
description:
|
||
'Override the size of the <code>/tmp</code> tmpfs mount inside the nsjail sandbox (in MB). When left empty, defaults to 800MB. Only applies when <em>Nsjail /tmp backing</em> is RAM (tmpfs).',
|
||
fieldType: 'number',
|
||
placeholder: '800',
|
||
storage: 'setting'
|
||
},
|
||
{
|
||
label: 'Sandbox image max size (MB)',
|
||
key: 'sandbox_image_max_size_mb',
|
||
description:
|
||
'Reject a <code># sandbox <image></code> whose compressed download size exceeds this many MB, before any layer is downloaded. Leave empty for no limit.',
|
||
fieldType: 'number',
|
||
placeholder: 'no limit',
|
||
storage: 'setting'
|
||
},
|
||
{
|
||
label: 'Sandbox image cache cap (MB)',
|
||
key: 'sandbox_image_cache_max_mb',
|
||
description:
|
||
"Best-effort cap on the worker's cached sandbox rootfs tars. When exceeded, the oldest (by creation time) are evicted after a run. Leave empty for unbounded.",
|
||
fieldType: 'number',
|
||
placeholder: 'unbounded',
|
||
storage: 'setting'
|
||
},
|
||
{
|
||
label: 'Sandbox image pull policy',
|
||
key: 'sandbox_image_pull_policy',
|
||
description:
|
||
'When to re-pull a <code># sandbox</code> image. <strong>newer</strong> (default) re-pulls only when the registry digest changed, so moving tags like <code>:latest</code> stay fresh without re-downloading unchanged layers. <strong>missing</strong> pulls only if absent (fastest, tags can go stale). <strong>always</strong> re-checks every job.',
|
||
fieldType: 'select',
|
||
storage: 'setting',
|
||
placeholder: 'newer',
|
||
defaultValue: () => 'newer',
|
||
select_items: [
|
||
{ label: 'Newer (default)', value: 'newer' },
|
||
{ label: 'Missing', value: 'missing' },
|
||
{ label: 'Always', value: 'always' },
|
||
{ label: 'Never', value: 'never' }
|
||
]
|
||
},
|
||
{
|
||
label: 'Sandbox image default registry',
|
||
key: 'sandbox_image_default_registry',
|
||
description:
|
||
'If set, unqualified <code># sandbox</code> images (e.g. <code>alpine</code>) are pulled from this registry instead of <code>docker.io</code>. Fully-qualified refs (e.g. <code>ghcr.io/org/img</code>) are unaffected. Example: <code>myregistry.example.com</code>.',
|
||
fieldType: 'text',
|
||
placeholder: 'docker.io',
|
||
storage: 'setting'
|
||
},
|
||
{
|
||
label: 'Sandbox registry auth',
|
||
key: 'sandbox_registry_auth',
|
||
description:
|
||
'Credentials for private registries used by <code># sandbox</code> images, in docker <code>config.json</code> / <code>auth.json</code> format. Written to a per-job <code>DOCKER_CONFIG</code> dir (removed with the job) and used by crane for the pull.',
|
||
fieldType: 'codearea',
|
||
codeAreaLang: 'json',
|
||
placeholder:
|
||
'{\n "auths": {\n "myregistry.example.com": {\n "auth": "BASE64(username:password)"\n }\n }\n}',
|
||
storage: 'setting'
|
||
},
|
||
{
|
||
label: 'SSH execution (#ssh)',
|
||
key: 'ssh_execution_enabled',
|
||
fieldType: 'boolean',
|
||
description:
|
||
'Allow bash scripts starting with a <code>#ssh <resource_path></code> directive to run on the remote host described by the referenced <code>ssh_target</code> resource instead of the worker. Off by default.',
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
hideInQuickSetup: true
|
||
},
|
||
{
|
||
label: 'Default timeout',
|
||
key: 'job_default_timeout',
|
||
description:
|
||
'Default timeout for individual jobs. <a href="https://www.windmill.dev/docs/core_concepts/jobs#retention-policy">Learn more</a>',
|
||
fieldType: 'seconds',
|
||
storage: 'setting',
|
||
cloudonly: false
|
||
},
|
||
{
|
||
label: 'Max timeout for sync endpoints',
|
||
description:
|
||
'Maximum amount of time (measured in seconds) that a <a href="https://www.windmill.dev/docs/core_concepts/webhooks">sync endpoint</a> is allowed to run before it is forcibly stopped or timed out.',
|
||
key: 'timeout_wait_result',
|
||
fieldType: 'seconds',
|
||
placeholder: '60',
|
||
storage: 'setting'
|
||
},
|
||
{
|
||
label: 'Keep job directories for debug',
|
||
key: 'keep_job_dir',
|
||
fieldType: 'boolean',
|
||
description: 'Keep Job directories after execution at /tmp/windmill/WORKER/JOB_ID',
|
||
storage: 'setting'
|
||
},
|
||
{
|
||
label: 'Workspace fairness — enabled',
|
||
description:
|
||
'Multi-tenant safeguard against a single workspace dominating the shared worker pool. <strong>Only relevant on instances where multiple workspaces share one worker group</strong> — single-tenant deployments do not need this. When a workspace accounts for at least <em>Workspace fairness — max percent</em> of cluster activity over the last <em>Workspace fairness — duration</em> seconds, each worker pull stochastically excludes that workspace so its share converges to the cap without on/off oscillation. Idle workers always fall back to running its jobs, so capping never starves the queue.',
|
||
key: 'workspace_fairness_enabled',
|
||
fieldType: 'boolean',
|
||
storage: 'setting',
|
||
cloudonly: false,
|
||
ee_only:
|
||
'Workspace fairness is an Enterprise feature — only useful on multi-tenant clusters where one noisy workspace would otherwise degrade QoS for other workspaces sharing the same worker pool.',
|
||
hideInQuickSetup: true
|
||
},
|
||
{
|
||
label: 'Workspace fairness — max percent',
|
||
description:
|
||
'Maximum share of cluster activity any single workspace may sustain before being stochastically throttled by the pull query. The admitted probability for capped workspaces is set just above this value so the cap is statistically stable rather than oscillating. Default 50.',
|
||
key: 'workspace_fairness_max_percent',
|
||
fieldType: 'number',
|
||
placeholder: '50',
|
||
storage: 'setting',
|
||
cloudonly: false,
|
||
ee_only: 'Workspace fairness is an Enterprise feature.',
|
||
hideInQuickSetup: true
|
||
},
|
||
{
|
||
label: 'Workspace fairness — duration (seconds)',
|
||
description:
|
||
'Rolling window used to measure workspace share. Activity = currently running jobs ∪ jobs completed in the last N seconds. Default 10.',
|
||
key: 'workspace_fairness_duration_secs',
|
||
fieldType: 'seconds',
|
||
placeholder: '10',
|
||
storage: 'setting',
|
||
cloudonly: false,
|
||
ee_only: 'Workspace fairness is an Enterprise feature.',
|
||
hideInQuickSetup: true
|
||
},
|
||
{
|
||
label: 'Workspace fairness — minimum total jobs',
|
||
description:
|
||
'Cap is only applied when cluster-wide activity exceeds this floor. Prevents over-eager capping on small clusters or quiet periods. Default 4.',
|
||
key: 'workspace_fairness_min_total_jobs',
|
||
fieldType: 'number',
|
||
placeholder: '4',
|
||
storage: 'setting',
|
||
cloudonly: false,
|
||
ee_only: 'Workspace fairness is an Enterprise feature.',
|
||
hideInQuickSetup: true
|
||
},
|
||
{
|
||
label: 'Max jobs queued per concurrency key',
|
||
description:
|
||
'Rejects new jobs once this many are already queued behind one concurrency key. Jobs sharing a key run at most <em>concurrent limit</em> at a time regardless of spare worker capacity, so a caller pushing faster than the key drains grows a backlog no capacity can absorb. Scoped per key, so a runaway producer cannot block the rest of the workspace. Set 0 to disable. Default 10000.',
|
||
key: 'concurrency_key_max_queued_jobs',
|
||
fieldType: 'number',
|
||
placeholder: '10000',
|
||
storage: 'setting',
|
||
cloudonly: true,
|
||
ee_only: '',
|
||
hideInQuickSetup: true
|
||
},
|
||
{
|
||
label: 'Max jobs queued per workspace',
|
||
description:
|
||
'Rejects new jobs once a workspace has this many queued in total, across every concurrency key and script. Guards against a single workspace flooding the queue generally, including from parallel for-loops. Applies even to premium workspaces. Jobs already queued still drain; only new pushes past the ceiling are rejected. Set 0 to disable. Default 20000.',
|
||
key: 'workspace_max_queued_jobs',
|
||
fieldType: 'number',
|
||
placeholder: '20000',
|
||
storage: 'setting',
|
||
cloudonly: true,
|
||
ee_only: '',
|
||
hideInQuickSetup: true
|
||
}
|
||
],
|
||
'Object Storage': [
|
||
{
|
||
label: 'Instance object storage',
|
||
description:
|
||
' S3/Azure bucket to store large logs and global cache for Python and Go. <a href="https://www.windmill.dev/docs/core_concepts/object_storage_in_windmill#instance-object-storage">Learn more</a>',
|
||
key: 'object_store_cache_config',
|
||
fieldType: 'object_store_config',
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
isValid: (v) => {
|
||
if (!v || v.type !== 'Gcs') return true
|
||
return v.serviceAccountKey !== undefined
|
||
}
|
||
},
|
||
{
|
||
label: 'Delete logs from s3 periodically',
|
||
description:
|
||
'Job and service logs are periodically deleted from disk when they expire. When this setting is on, they are also deleted from object storage. Defaults to on when object storage is configured; turn off to keep logs in object storage indefinitely.',
|
||
key: 'monitor_logs_on_s3',
|
||
fieldType: 'boolean',
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
},
|
||
{
|
||
label: 'Back AI sessions up to the instance object storage',
|
||
description:
|
||
"Browsers back their AI sessions up to their workspace's object storage, encrypted with the workspace key. When this is on and instance object storage is configured, a workspace without object storage of its own uses the instance object storage instead, under the same encryption; configuring a storage for the workspace moves its backups there and deletes what it kept in the instance storage. On by default; turn off to keep the AI sessions of such workspaces in the browser only.",
|
||
key: 'ai_sessions_instance_storage_fallback',
|
||
fieldType: 'boolean',
|
||
defaultValue: () => true,
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
hideInQuickSetup: true
|
||
},
|
||
{
|
||
label: 'Store audit logs in object storage',
|
||
description:
|
||
'When enabled and instance object storage is configured, audit logs are also exported as newline-delimited JSON to the dedicated logs/audit/ folder (partitioned by day). Export is incremental and runs off the hot path. Enabling (or re-enabling) anchors the export at ~now: while it stays enabled, every audit log committed from that point on is exported (transactions in flight at the moment of enabling may include a bounded set of just-prior rows). Pre-existing history, and any window during which export was disabled, are NOT exported by this cursor — use the opt-in backfill API to export a chosen historical range, back to when audit-log partitioning was introduced (older rows in the legacy audit table are not exported, and a window overlapping them is rejected): POST /settings/audit_logs_s3_backfill {from, to} (status at GET /settings/audit_logs_s3_backfill_status).',
|
||
key: 'store_audit_logs_s3',
|
||
fieldType: 'boolean',
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
hideInQuickSetup: true
|
||
},
|
||
{
|
||
label: 'Auto-build binaries on deployment',
|
||
description:
|
||
'When enabled and instance object storage is configured, deploying a Rust, Go or C# script queues a job that compiles it and uploads the binary to object storage, so the first run does not pay the compile. Requires instance object storage: without it the binary would only reach the building worker. Does nothing for languages whose artifact is not cached in object storage.',
|
||
key: 'auto_build_binary_on_deploy',
|
||
fieldType: 'boolean',
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
hideInQuickSetup: true
|
||
},
|
||
{
|
||
label: 'Auto-build worker tag',
|
||
description:
|
||
'Worker tag the auto-build jobs run on. Leave empty to use the script language tag, where its dependency job already runs. Set it to pin builds to a pool that has the toolchain and matches the platform of your runtime workers — the cache key includes the OS and architecture, so a binary built elsewhere is never reused. Note that compiling runs script-author-controlled build steps (Cargo build scripts, MSBuild targets, cgo) with exactly the isolation the cold build of a first run has — nsjail for Rust when job isolation is on, none for the Go and C# compilers — so this pool now executes them at deploy time rather than at first run.',
|
||
key: 'auto_build_binary_tag',
|
||
fieldType: 'text',
|
||
placeholder: 'e.g. build',
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
hideInQuickSetup: true
|
||
}
|
||
],
|
||
'Private Hub': [
|
||
{
|
||
label: 'Private Hub base url',
|
||
description:
|
||
'Base URL of your Private Hub instance, without trailing slash. <a href="https://www.windmill.dev/docs/core_concepts/private_hub">Learn more</a>',
|
||
placeholder: 'https://hub.company.com',
|
||
key: 'hub_base_url',
|
||
fieldType: 'text',
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
advancedToggle: {
|
||
label: 'I have a different URL for Hub access from end-user browsers',
|
||
onChange(values) {
|
||
if (values['hub_accessible_url']) {
|
||
values['hub_accessible_url'] = null
|
||
} else {
|
||
values['hub_accessible_url'] = values['hub_base_url'] || 'https://hub.company.com'
|
||
}
|
||
return values
|
||
},
|
||
checked: (values) => values['hub_accessible_url'] != null
|
||
},
|
||
requiresReloadOnChange: true
|
||
},
|
||
{
|
||
label: 'Private Hub accessible url',
|
||
description:
|
||
'Base URL accessible from end-user browsers, without trailing slash. <a href="https://www.windmill.dev/docs/core_concepts/private_hub">Learn more</a>',
|
||
key: 'hub_accessible_url',
|
||
fieldType: 'text',
|
||
hiddenIfNull: true,
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
requiresReloadOnChange: true
|
||
},
|
||
{
|
||
label: 'Private Hub API secret',
|
||
description:
|
||
'If access to your Private Hub is restricted, you can set the hub API secret here. <a href="https://www.windmill.dev/docs/core_concepts/private_hub">Learn more</a>',
|
||
key: 'hub_api_secret',
|
||
fieldType: 'password',
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
},
|
||
{
|
||
label: 'Azure OpenAI base path',
|
||
description:
|
||
'All workspaces using an OpenAI resource for Windmill AI will run against the specified Azure resource. Format: https://{your-resource-name}.openai.azure.com/openai/deployments/{deployment-id} — keep the URL as stored; the model comes from each workspace\'s configured model list, whose entries must be your Azure deployment names. <a href="https://www.windmill.dev/docs/core_concepts/ai_generation#azure-openai-advanced-models">Learn more</a>',
|
||
key: 'openai_azure_base_path',
|
||
fieldType: 'text',
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
hiddenIfEmpty: true
|
||
},
|
||
{
|
||
label: 'Disable Hub',
|
||
description:
|
||
'Disable the Windmill Hub integration entirely. Enable this if your instance runs in a closed environment without internet access and you do not have a private hub setup.',
|
||
key: 'disable_hub',
|
||
fieldType: 'boolean',
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
requiresReloadOnChange: true
|
||
}
|
||
],
|
||
SMTP: [
|
||
{
|
||
label: 'SMTP configuration',
|
||
key: 'smtp_settings',
|
||
fieldType: 'smtp_connect',
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
},
|
||
{
|
||
label: 'Disable workspace invite emails',
|
||
description:
|
||
'Do not send email notifications when a user is invited or added to a workspace. Useful for automated workflows that add users programmatically.',
|
||
key: 'disable_workspace_invite_emails',
|
||
fieldType: 'boolean',
|
||
storage: 'setting'
|
||
}
|
||
],
|
||
'Auth/OAuth/SAML': [
|
||
{
|
||
label: 'Disable password login',
|
||
description:
|
||
'Hide the email/password form on the login page and reject password login requests. Use when you only want OAuth/SAML logins.',
|
||
key: 'disable_password_login',
|
||
fieldType: 'boolean',
|
||
storage: 'setting'
|
||
},
|
||
{
|
||
label: 'Auto-login SSO provider',
|
||
description:
|
||
'If set, the login page redirects automatically to this provider. Use the OAuth provider key (e.g. "okta", "google") or "saml". The provider must be configured; otherwise the setting is ignored. Visit /user/login?no_sso=1 to bypass the redirect and fall back to the normal login form.',
|
||
key: 'auto_login_provider',
|
||
fieldType: 'text',
|
||
placeholder: 'okta',
|
||
storage: 'setting'
|
||
},
|
||
{
|
||
label: 'SSO groups claim',
|
||
description:
|
||
'Name of the SAML attribute or OIDC userinfo claim carrying the user\'s IdP groups ("http://schemas.microsoft.com/ws/2008/06/identity/claims/groups" on Entra SAML, "groups" for most OIDC providers). Its values must be the same group ids that SCIM stored as the instance groups\' external id (Entra emits object ids in both), since matching is by external id only. When set, every SSO login reconciles the user\'s membership in those SCIM-provisioned instance groups against the claim, so IdP group changes take effect at the next login instead of waiting for the SCIM push. Instance groups without an external id are never touched, and a login whose claim is absent or empty changes nothing. Leave empty to disable.',
|
||
key: 'sso_groups_claim',
|
||
fieldType: 'text',
|
||
placeholder: 'groups',
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
}
|
||
],
|
||
'DB Health': [],
|
||
Registries: [
|
||
{
|
||
label: 'Instance Python Version',
|
||
description: 'Default python version for newly deployed scripts',
|
||
key: 'instance_python_version',
|
||
fieldType: 'select_python',
|
||
// To change latest stable version:
|
||
// 1. Change placeholder in instanceSettings.ts
|
||
// 2. Change LATEST_STABLE_PY in dockerfile
|
||
// 3. Change #[default] annotation for PyVersion in backend
|
||
placeholder: '3.10,3.11,3.12,3.13',
|
||
select_items: [
|
||
{
|
||
label: 'Latest Stable',
|
||
value: 'default',
|
||
tooltip: 'python-3.12'
|
||
},
|
||
{
|
||
label: '3.10'
|
||
},
|
||
{
|
||
label: '3.11'
|
||
},
|
||
{
|
||
label: '3.12'
|
||
},
|
||
{
|
||
label: '3.13'
|
||
}
|
||
],
|
||
storage: 'setting'
|
||
},
|
||
{
|
||
label: 'UV index url',
|
||
description: 'Add private Pip registry',
|
||
key: 'pip_index_url',
|
||
fieldType: 'password',
|
||
placeholder: 'https://username:password@pypi.company.com/simple',
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
},
|
||
{
|
||
label: 'UV extra index url',
|
||
description: 'Add private extra Pip registry',
|
||
key: 'pip_extra_index_url',
|
||
fieldType: 'password',
|
||
placeholder: 'https://username:password@pypi.company.com/simple',
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
},
|
||
{
|
||
label: 'UV Python install mirror',
|
||
description:
|
||
'Mirror URL for downloading managed Python interpreters. Wires to <code>UV_PYTHON_INSTALL_MIRROR</code>. See <a href="https://docs.astral.sh/uv/configuration/environment/#uv_python_install_mirror">uv docs</a>.',
|
||
key: 'uv_python_install_mirror',
|
||
fieldType: 'text',
|
||
placeholder: 'https://mirror.example.com/python-build-standalone',
|
||
storage: 'setting'
|
||
},
|
||
{
|
||
label: 'UV index strategy',
|
||
description:
|
||
'Strategy for resolving packages from multiple indexes. See <a href="https://docs.astral.sh/uv/pip/compatibility/#packages-that-exist-on-multiple-indexes">uv docs</a>',
|
||
key: 'uv_index_strategy',
|
||
fieldType: 'select',
|
||
placeholder: 'unsafe-best-match',
|
||
defaultValue: () => 'unsafe-best-match',
|
||
select_items: [
|
||
{
|
||
label: 'first-index',
|
||
tooltip: 'Only use the first index that contains the package'
|
||
},
|
||
{
|
||
label: 'unsafe-first-match',
|
||
tooltip: 'Search for packages across all indexes, preferring the first match'
|
||
},
|
||
{
|
||
label: 'unsafe-best-match (default)',
|
||
value: 'unsafe-best-match',
|
||
tooltip: 'Search for packages across all indexes, preferring the best match'
|
||
}
|
||
],
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
},
|
||
{
|
||
label: 'NPM Registry Configuration (.npmrc)',
|
||
description:
|
||
'Full .npmrc file content for private npm registries. Used by Bun, Deno, and the npm proxy. Takes precedence over the legacy fields below.',
|
||
key: 'npmrc',
|
||
fieldType: 'codearea',
|
||
codeAreaLang: 'ini',
|
||
placeholder:
|
||
'registry=https://registry.mycompany.com/\n//registry.mycompany.com/:_authToken=YOUR_TOKEN\n\n@myorg:registry=https://registry.myorg.com/\n//registry.myorg.com/:_authToken=SCOPED_TOKEN',
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
},
|
||
{
|
||
label: 'Npm config registry (legacy)',
|
||
description: 'Add private npm registry. Prefer using the .npmrc field above.',
|
||
key: 'npm_config_registry',
|
||
fieldType: 'password',
|
||
placeholder: 'https://registry.npmjs.org/:_authToken=npm_FOOBAR',
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
hiddenIfEmpty: true
|
||
},
|
||
{
|
||
label: 'Bunfig install scopes (legacy)',
|
||
description:
|
||
'Add private scoped registries for Bun. Prefer using the .npmrc field above. See: https://bun.sh/docs/install/registries',
|
||
key: 'bunfig_install_scopes',
|
||
fieldType: 'password',
|
||
placeholder: '"@myorg3" = { token = "mytoken", url = "https://registry.myorg.com/" }',
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
hiddenIfEmpty: true
|
||
},
|
||
{
|
||
label: 'Minimum release age (uv / Python)',
|
||
description:
|
||
'Refuse to install Python packages younger than this many seconds. Protects against supply-chain attacks via freshly published versions. Wires to <code>uv pip --exclude-newer</code>.',
|
||
key: 'uv_exclude_newer',
|
||
fieldType: 'seconds',
|
||
placeholder: '604800',
|
||
storage: 'setting'
|
||
},
|
||
{
|
||
label: 'Minimum release age (bun / npm)',
|
||
description:
|
||
'Refuse to install npm packages younger than this many seconds. Protects against supply-chain attacks via freshly published versions. Sets <code>BUN_INSTALL_MINIMUM_RELEASE_AGE</code>.',
|
||
key: 'bun_install_min_release_age',
|
||
fieldType: 'seconds',
|
||
placeholder: '604800',
|
||
storage: 'setting'
|
||
},
|
||
{
|
||
label: 'Nuget Config',
|
||
description:
|
||
'Write a nuget.config file to set custom package sources and credentials. Use <clear /> inside <packageSources> to remove default sources and only use your custom ones',
|
||
key: 'nuget_config',
|
||
fieldType: 'codearea',
|
||
codeAreaLang: 'xml',
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
},
|
||
{
|
||
label: 'Maven/Ivy repositories',
|
||
description: 'Add private Maven/Ivy repositories',
|
||
key: 'maven_repos',
|
||
fieldType: 'password',
|
||
placeholder: 'https://user:password@artifacts.foo.com/maven',
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
},
|
||
{
|
||
label: 'Maven settings.xml',
|
||
description:
|
||
'Write a Maven settings.xml file for custom repositories, mirrors, and credentials',
|
||
key: 'maven_settings_xml',
|
||
fieldType: 'codearea',
|
||
codeAreaLang: 'xml',
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
},
|
||
{
|
||
label: 'Disable default Maven repository',
|
||
description: 'Do not use default Maven repository',
|
||
key: 'no_default_maven',
|
||
fieldType: 'boolean',
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
},
|
||
{
|
||
label: 'Ruby Gems repositories',
|
||
description: 'Add private Ruby repositories with credentials. Should end with /',
|
||
key: 'ruby_repos',
|
||
fieldType: 'password',
|
||
placeholder: 'https://user:password@gems.foo.com/',
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
},
|
||
{
|
||
label: 'Cargo registries',
|
||
description: 'Write a .cargo/config.toml to set custom Cargo registries and credentials',
|
||
key: 'cargo_registries',
|
||
fieldType: 'codearea',
|
||
codeAreaLang: 'toml',
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
},
|
||
{
|
||
label: 'PowerShell Repository URL',
|
||
description: 'Add private PowerShell repository URL',
|
||
key: 'powershell_repo_url',
|
||
placeholder:
|
||
'https://pkgs.dev.azure.com/<org>/<project>/_packaging/<feed>/nuget/v3/index.json',
|
||
fieldType: 'text',
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
},
|
||
{
|
||
label: 'PowerShell Repository PAT',
|
||
description:
|
||
'Add private PowerShell repository Personal Access Token (optional, for authenticated repositories)',
|
||
key: 'powershell_repo_pat',
|
||
fieldType: 'password',
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
}
|
||
],
|
||
Alerts: [
|
||
{
|
||
label: 'Critical alert channels',
|
||
description:
|
||
'Channels to send critical alerts to. <a href="https://www.windmill.dev/docs/core_concepts/critical_alerts">Learn more</a>',
|
||
key: 'critical_error_channels',
|
||
fieldType: 'critical_error_channels',
|
||
storage: 'setting',
|
||
ee_only: 'Channels other than tracing are only available in the EE version',
|
||
actionButton: {
|
||
label: 'Test all channels',
|
||
onclick: async (values) => {
|
||
const { SettingService } = await import('$lib/gen')
|
||
const { sendUserToast } = await import('$lib/toast')
|
||
try {
|
||
await SettingService.testCriticalChannels({
|
||
requestBody: values.critical_error_channels
|
||
})
|
||
sendUserToast('Test message sent successfully to critical channels', false)
|
||
} catch (error: any) {
|
||
sendUserToast('Failed to send test message: ' + error.message, true)
|
||
}
|
||
},
|
||
variant: 'accent'
|
||
}
|
||
},
|
||
{
|
||
label: 'Mute critical alerts in UI',
|
||
description: 'Enable to mute critical alerts in the UI',
|
||
key: 'critical_alert_mute_ui',
|
||
fieldType: 'boolean',
|
||
storage: 'setting',
|
||
requiresReloadOnChange: true,
|
||
ee_only: 'Critical alerts in UI are only available in the EE version'
|
||
},
|
||
{
|
||
label: 'Alert on token expiry',
|
||
description:
|
||
'Send critical alerts when API tokens are about to expire (within 7 days) or have expired',
|
||
key: 'critical_alerts_on_token_expiry',
|
||
fieldType: 'boolean',
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
},
|
||
{
|
||
label: 'Mute zombie job restart alerts',
|
||
description:
|
||
'Stop sending critical alerts when a zombie job or flow is detected and automatically restarted. Jobs that exhaust all their restart attempts, and flows cancelled after hanging between steps, keep alerting.',
|
||
key: 'critical_alert_mute_zombie_job_restart',
|
||
fieldType: 'boolean',
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
},
|
||
{
|
||
label: 'Slack',
|
||
key: 'slack',
|
||
fieldType: 'slack_connect',
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
},
|
||
{
|
||
label: 'Alert on DB oversize',
|
||
key: 'critical_alerts_on_db_oversize',
|
||
description: 'Alert if DB grows more than specified size',
|
||
fieldType: 'critical_alerts_on_db_oversize',
|
||
placeholder: '100',
|
||
storage: 'setting',
|
||
ee_only: ''
|
||
}
|
||
],
|
||
Webhooks: [
|
||
{
|
||
label: 'Instance Events Webhook',
|
||
description:
|
||
'URL to receive POST requests for instance events (user added, OAuth signup, user invited/added/joined workspace).',
|
||
key: 'instance_events_webhook',
|
||
fieldType: 'text',
|
||
placeholder: 'https://example.com/webhook',
|
||
storage: 'setting'
|
||
}
|
||
],
|
||
'OTEL/Prom': [
|
||
{
|
||
label: 'OpenTelemetry',
|
||
key: 'otel',
|
||
fieldType: 'otel',
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
triggersRestart: true
|
||
},
|
||
{
|
||
label: 'HTTP Request Tracing',
|
||
description:
|
||
'Capture HTTP/HTTPS requests from job scripts as OpenTelemetry spans. Visible in job details and exported to your OTEL collector if configured. Toggling restarts workers.',
|
||
key: 'otel_tracing_proxy',
|
||
fieldType: 'otel_tracing_proxy',
|
||
storage: 'setting',
|
||
ee_only: 'HTTP Request Tracing is an EE feature',
|
||
triggersRestart: true,
|
||
defaultValue: () => ({ enabled: false, enabled_languages: [...OTEL_TRACING_PROXY_LANGUAGES] })
|
||
},
|
||
{
|
||
label: 'HTTP Request Tracing retention in secs',
|
||
key: 'otel_traces_retention_secs',
|
||
description:
|
||
'How long a captured HTTP request span is kept in the database, and therefore how far back the job details view can show a job its requests. Independent of the job retention period, so a span may outlive its job or be swept while the job remains. Defaults to 7 days. Leave it empty for the default.',
|
||
fieldType: 'seconds',
|
||
storage: 'setting',
|
||
cloudonly: false,
|
||
// Badged EE because only the EE proxy captures spans, but deliberately no
|
||
// `ceMaxSeconds`: a CE build still sweeps rows an EE-era instance left behind, and
|
||
// the backend accepts the same range on either edition.
|
||
ee_only: 'HTTP Request Tracing is an EE feature',
|
||
error:
|
||
'HTTP Request Tracing retention must be between 1 second and 100 years, leave it empty for the default',
|
||
isValid: (value: any) =>
|
||
value == undefined ||
|
||
(typeof value === 'number' && value > 0 && value <= 60 * 60 * 24 * 365 * 100)
|
||
},
|
||
{
|
||
label: 'Prometheus',
|
||
description:
|
||
'Expose Prometheus metrics for workers and servers on port 8001 at /metrics. <a target="_blank" href="https://www.windmill.dev/docs/advanced/instance_settings#expose-metrics">Learn more</a>',
|
||
key: 'expose_metrics',
|
||
fieldType: 'boolean',
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
triggersRestart: true
|
||
}
|
||
],
|
||
'Service logs': [
|
||
{
|
||
label: 'Retention in secs',
|
||
key: 'service_log_retention_secs',
|
||
description:
|
||
'How long a service log is kept, across every copy of it: the entry in the database, the file on the disk of the process that wrote it, and — once instance object storage is configured and the indexer has ingested it — its line in the columnar store that search and the log viewer read. Search reaches back at most this far, and less when the indexer time window under Indexer is shorter. Defaults to 14 days. There is no keep-forever setting here — leave it empty for the default.',
|
||
fieldType: 'seconds',
|
||
storage: 'setting',
|
||
cloudonly: false,
|
||
error:
|
||
'Service log retention must be between 1 second and 100 years — leave it empty for the default',
|
||
isValid: (value: any) =>
|
||
value == undefined ||
|
||
(typeof value === 'number' && value > 0 && value <= 60 * 60 * 24 * 365 * 100)
|
||
}
|
||
],
|
||
|
||
Indexer: [
|
||
{
|
||
label: '',
|
||
key: 'indexer_settings',
|
||
fieldType: 'indexer_rates',
|
||
storage: 'setting',
|
||
validate: validateIndexerSettings
|
||
}
|
||
],
|
||
|
||
Telemetry: [
|
||
{
|
||
label: 'Minimal telemetry',
|
||
key: 'disable_stats',
|
||
fieldType: 'boolean',
|
||
storage: 'setting'
|
||
}
|
||
],
|
||
'Secret Storage': [
|
||
{
|
||
label: 'Backend type',
|
||
description:
|
||
'By default, secrets are encrypted and stored in the database. Enterprise Edition supports HashiCorp Vault, Azure Key Vault, and AWS Secrets Manager as external secret backends.',
|
||
key: 'secret_backend',
|
||
fieldType: 'secret_backend',
|
||
storage: 'setting',
|
||
ee_only:
|
||
'HashiCorp Vault, Azure Key Vault, and AWS Secrets Manager integrations are Enterprise Edition features'
|
||
}
|
||
],
|
||
'GitHub App': [
|
||
{
|
||
// The category header above already names the section; this labels the
|
||
// card that holds the app credentials, next to the webhook base url one.
|
||
label: 'App configuration',
|
||
description:
|
||
'Use your own GitHub App instead of the Windmill-managed one on stats.windmill.dev.',
|
||
key: 'github_enterprise_app',
|
||
fieldType: 'github_enterprise_app',
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
error:
|
||
'When self-managed mode is enabled, Base URL, App ID, App Slug, Client ID, and Private Key are required.',
|
||
isValid: (v: any) => {
|
||
if (!v?.self_managed) return true
|
||
return !!(v?.base_url && v?.app_id && v?.app_slug && v?.client_id && v?.private_key)
|
||
}
|
||
},
|
||
{
|
||
label: 'Webhook base url',
|
||
description:
|
||
'Base url GitHub delivers git sync webhooks to, without trailing slash. Leave empty to use the instance base url. Set it when GitHub cannot reach the base url and a separate ingress fronts this instance for inbound webhooks.',
|
||
key: 'github_app_webhook_base_url',
|
||
fieldType: 'webhook_base_url',
|
||
placeholder: 'https://windmill-webhooks.company.com',
|
||
storage: 'setting',
|
||
ee_only: '',
|
||
error:
|
||
'Webhook base url must be an http:// or https:// url with a host, no embedded username or password, no query string or fragment, and no trailing slash',
|
||
isValid: isValidWebhookBaseUrl
|
||
}
|
||
],
|
||
WebSocket: [
|
||
{
|
||
label: 'WebSocket connectivity',
|
||
description:
|
||
'Test connectivity to multiplayer, LSP, and debugger WebSocket services. Enable custom URL override for deployments where WebSocket traffic routes to a different host.',
|
||
key: 'ws_base_url',
|
||
fieldType: 'ws_connectivity',
|
||
storage: 'setting',
|
||
requiresReloadOnChange: true,
|
||
isValid: (value: string | undefined) =>
|
||
!value ||
|
||
(value.startsWith('ws') &&
|
||
value.includes('://') &&
|
||
!value.endsWith('/') &&
|
||
!value.endsWith(' '))
|
||
}
|
||
],
|
||
LSP: [
|
||
{
|
||
label: 'Ruff config (ruff.toml)',
|
||
description:
|
||
'Shared ruff.toml applied to the Python editor linter across the whole instance. The LSP container fetches this every minute and writes it next to edited files. Leave empty to use the Windmill default (<code>select = ["E4", "E7", "E9", "F"]</code>); anything set here replaces that default entirely. See <a href="https://docs.astral.sh/ruff/configuration/">ruff docs</a>',
|
||
key: 'ruff_config',
|
||
fieldType: 'codearea',
|
||
codeAreaLang: 'toml',
|
||
placeholder: 'line-length = 100\n\n[lint]\nselect = ["E", "F", "I"]\nignore = ["E501"]',
|
||
storage: 'setting'
|
||
}
|
||
]
|
||
}
|
||
|
||
export const settingsKeys = Object.keys(settings)
|
||
|
||
// --- Sidebar navigation for instance settings ---
|
||
export const instanceSettingsNavigationGroups = [
|
||
{
|
||
title: 'Core',
|
||
items: [
|
||
{
|
||
id: 'users',
|
||
label: 'Users',
|
||
aiId: 'instance-settings-users',
|
||
aiDescription: 'Instance users settings'
|
||
},
|
||
{
|
||
id: 'general',
|
||
label: 'General',
|
||
aiId: 'instance-settings-general',
|
||
aiDescription: 'Instance general settings'
|
||
},
|
||
{
|
||
id: 'jobs',
|
||
label: 'Jobs',
|
||
aiId: 'instance-settings-jobs',
|
||
aiDescription: 'Instance jobs settings'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
title: 'Authentication',
|
||
items: [
|
||
{
|
||
id: 'sso',
|
||
label: 'SSO',
|
||
aiId: 'instance-settings-sso',
|
||
aiDescription: 'Instance SSO settings'
|
||
},
|
||
{
|
||
id: 'oauth',
|
||
label: 'OAuth',
|
||
aiId: 'instance-settings-oauth',
|
||
aiDescription: 'Instance OAuth settings'
|
||
},
|
||
{
|
||
id: 'scim_saml',
|
||
label: 'SCIM/SAML',
|
||
aiId: 'instance-settings-scim-saml',
|
||
aiDescription: 'Instance SCIM/SAML settings',
|
||
isEE: true
|
||
}
|
||
]
|
||
},
|
||
{
|
||
title: 'Infrastructure',
|
||
items: [
|
||
{
|
||
id: 'smtp',
|
||
label: 'SMTP',
|
||
aiId: 'instance-settings-smtp',
|
||
aiDescription: 'Instance SMTP settings'
|
||
},
|
||
{
|
||
id: 'registries',
|
||
label: 'Registries',
|
||
aiId: 'instance-settings-registries',
|
||
aiDescription: 'Instance registries settings'
|
||
},
|
||
{
|
||
id: 'object_storage',
|
||
label: 'Object Storage',
|
||
aiId: 'instance-settings-object-storage',
|
||
aiDescription: 'Instance object storage settings',
|
||
isEE: true
|
||
}
|
||
]
|
||
},
|
||
{
|
||
title: 'Monitoring',
|
||
items: [
|
||
{
|
||
id: 'alerts',
|
||
label: 'Alerts',
|
||
aiId: 'instance-settings-alerts',
|
||
aiDescription: 'Instance alerts settings',
|
||
isEE: true
|
||
},
|
||
{
|
||
id: 'webhooks',
|
||
label: 'Webhooks',
|
||
aiId: 'instance-settings-webhooks',
|
||
aiDescription: 'Instance events webhook settings'
|
||
},
|
||
{
|
||
id: 'otel_prom',
|
||
label: 'OTEL/Prometheus',
|
||
aiId: 'instance-settings-otel-prom',
|
||
aiDescription: 'Instance OTEL/Prometheus settings',
|
||
isEE: true
|
||
},
|
||
{
|
||
id: 'service_logs',
|
||
label: 'Service logs',
|
||
aiId: 'instance-settings-service-logs',
|
||
aiDescription: 'Service log retention settings'
|
||
},
|
||
{
|
||
id: 'indexer',
|
||
label: 'Indexer',
|
||
aiId: 'instance-settings-indexer',
|
||
aiDescription: 'Instance indexer settings',
|
||
isEE: true
|
||
},
|
||
{
|
||
id: 'db_health',
|
||
label: 'DB Health',
|
||
aiId: 'instance-settings-db-health',
|
||
aiDescription: 'Database health diagnostics and performance insights'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
title: 'AI',
|
||
items: [
|
||
{
|
||
id: 'ai',
|
||
label: 'AI',
|
||
aiId: 'instance-settings-ai',
|
||
aiDescription: 'Instance AI settings (providers, models, prompts)'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
title: 'Advanced',
|
||
items: [
|
||
{
|
||
id: 'github_enterprise_app',
|
||
label: 'GitHub App',
|
||
aiId: 'instance-settings-github-enterprise-app',
|
||
aiDescription: 'Self-managed GitHub App for git sync',
|
||
isEE: true
|
||
},
|
||
{
|
||
id: 'private_hub',
|
||
label: 'Private Hub',
|
||
aiId: 'instance-settings-private-hub',
|
||
aiDescription: 'Instance private hub settings',
|
||
isEE: true
|
||
},
|
||
{
|
||
id: 'telemetry',
|
||
label: 'Telemetry',
|
||
aiId: 'instance-settings-telemetry',
|
||
aiDescription: 'Instance telemetry settings'
|
||
},
|
||
{
|
||
id: 'secret_storage',
|
||
label: 'Secret Storage',
|
||
aiId: 'instance-settings-secret-storage',
|
||
aiDescription: 'Instance secret storage settings'
|
||
},
|
||
{
|
||
id: 'websocket',
|
||
label: 'WebSocket',
|
||
aiId: 'instance-settings-websocket',
|
||
aiDescription: 'WebSocket connectivity test and URL override'
|
||
},
|
||
{
|
||
id: 'lsp',
|
||
label: 'LSP',
|
||
aiId: 'instance-settings-lsp',
|
||
aiDescription: 'Language server protocol settings (ruff config, editor linting)'
|
||
}
|
||
]
|
||
}
|
||
]
|
||
|
||
export const tabToCategoryMap: Record<string, string> = {
|
||
general: 'Core',
|
||
ai: 'AI',
|
||
sso: 'Auth/OAuth/SAML',
|
||
oauth: 'Auth/OAuth/SAML',
|
||
scim_saml: 'Auth/OAuth/SAML',
|
||
smtp: 'SMTP',
|
||
registries: 'Registries',
|
||
alerts: 'Alerts',
|
||
webhooks: 'Webhooks',
|
||
otel_prom: 'OTEL/Prom',
|
||
indexer: 'Indexer',
|
||
service_logs: 'Service logs',
|
||
telemetry: 'Telemetry',
|
||
secret_storage: 'Secret Storage',
|
||
object_storage: 'Object Storage',
|
||
jobs: 'Jobs',
|
||
private_hub: 'Private Hub',
|
||
github_enterprise_app: 'GitHub App',
|
||
websocket: 'WebSocket',
|
||
db_health: 'DB Health',
|
||
lsp: 'LSP'
|
||
}
|
||
|
||
export const tabToAuthSubTab: Record<string, 'sso' | 'oauth' | 'scim'> = {
|
||
sso: 'sso',
|
||
oauth: 'oauth',
|
||
scim_saml: 'scim'
|
||
}
|
||
|
||
// Navigation groups for the initial setup flow (no Users tab)
|
||
export const setupNavigationGroups = instanceSettingsNavigationGroups
|
||
.map((group) => ({
|
||
...group,
|
||
items: group.items.filter((item) => item.id !== 'users')
|
||
}))
|
||
.filter((group) => group.items.length > 0)
|
||
|
||
export const categoryToTabMap: Record<string, string> = {
|
||
Core: 'general',
|
||
AI: 'ai',
|
||
SMTP: 'smtp',
|
||
'Auth/OAuth/SAML': 'sso',
|
||
Registries: 'registries',
|
||
Alerts: 'alerts',
|
||
Webhooks: 'webhooks',
|
||
'OTEL/Prom': 'otel_prom',
|
||
Indexer: 'indexer',
|
||
'Service logs': 'service_logs',
|
||
Telemetry: 'telemetry',
|
||
'Secret Storage': 'secret_storage',
|
||
'Object Storage': 'object_storage',
|
||
Jobs: 'jobs',
|
||
'Private Hub': 'private_hub',
|
||
'GitHub App': 'github_enterprise_app',
|
||
WebSocket: 'websocket',
|
||
'DB Health': 'db_health',
|
||
LSP: 'lsp'
|
||
}
|
||
|
||
export interface SearchableSettingItem {
|
||
label: string
|
||
tabId: string
|
||
settingKey?: string
|
||
category: string
|
||
/** Full description text (HTML stripped), used for search matching only — not displayed */
|
||
description?: string
|
||
}
|
||
|
||
/**
|
||
* Extract the label portion from a uFuzzy marked/highlighted string.
|
||
* Only allows `<mark>` and `</mark>` tags through (sanitizes everything else).
|
||
*/
|
||
export function extractMarkedLabel(marked: string | undefined, labelLength: number): string {
|
||
if (!marked) return ''
|
||
let plainIdx = 0
|
||
let markedIdx = 0
|
||
while (plainIdx < labelLength && markedIdx < marked.length) {
|
||
if (marked[markedIdx] === '<') {
|
||
while (markedIdx < marked.length && marked[markedIdx] !== '>') markedIdx++
|
||
markedIdx++
|
||
} else if (marked[markedIdx] === '&') {
|
||
// SearchItems escapes the haystack, so one plain character can arrive
|
||
// as an entity. Skipping the whole entity keeps this offset walk in
|
||
// step with `labelLength`, which counts unescaped characters.
|
||
const end = marked.indexOf(';', markedIdx)
|
||
markedIdx = end === -1 ? markedIdx + 1 : end + 1
|
||
plainIdx++
|
||
} else {
|
||
plainIdx++
|
||
markedIdx++
|
||
}
|
||
}
|
||
// Include any closing </mark> right after
|
||
if (marked.startsWith('</mark>', markedIdx)) {
|
||
markedIdx += '</mark>'.length
|
||
}
|
||
// Sanitize: only allow <mark> and </mark> tags from uFuzzy highlight
|
||
return marked.slice(0, markedIdx).replace(/<(?!\/?mark>)[^>]*>/g, '')
|
||
}
|
||
|
||
export function buildSearchableSettingItems(
|
||
navigationGroups: typeof instanceSettingsNavigationGroups = instanceSettingsNavigationGroups
|
||
): SearchableSettingItem[] {
|
||
const items: SearchableSettingItem[] = []
|
||
|
||
// Add sidebar navigation items (tab-level)
|
||
for (const group of navigationGroups) {
|
||
for (const navItem of group.items) {
|
||
items.push({
|
||
label: navItem.label,
|
||
tabId: navItem.id,
|
||
category: group.title
|
||
})
|
||
}
|
||
}
|
||
|
||
// Add individual settings from each category
|
||
for (const [category, categorySettings] of Object.entries(settings)) {
|
||
const tabId = categoryToTabMap[category]
|
||
if (!tabId) continue
|
||
for (const setting of categorySettings) {
|
||
if (!setting.label) continue
|
||
items.push({
|
||
label: setting.label,
|
||
tabId,
|
||
settingKey: setting.key,
|
||
category,
|
||
description: setting.description?.replace(/<[^>]*>/g, '') ?? ''
|
||
})
|
||
}
|
||
}
|
||
|
||
// Add SCIM/SAML settings
|
||
for (const setting of scimSamlSetting) {
|
||
if (!setting.label) continue
|
||
items.push({
|
||
label: setting.label,
|
||
tabId: 'scim_saml',
|
||
settingKey: setting.key,
|
||
category: 'SCIM/SAML',
|
||
description: setting.description?.replace(/<[^>]*>/g, '') ?? ''
|
||
})
|
||
}
|
||
|
||
return items
|
||
}
|
||
|
||
/** Registry settings that support per-workspace overrides. Excludes instance_python_version, uv_index_strategy, and uv_python_install_mirror which are instance-wide only. */
|
||
export const WORKSPACE_REGISTRY_SETTINGS: Setting[] = settings['Registries'].filter(
|
||
(s) =>
|
||
s.key !== 'instance_python_version' &&
|
||
s.key !== 'uv_index_strategy' &&
|
||
s.key !== 'uv_python_install_mirror'
|
||
)
|