Files
windmill/frontend/src/lib/components/variables/VariablesList.svelte
T
Diego ImbertandRuben Fiszel ebb3048ca0 feat: mount session list pages in process instead of iframes (#11289)
* feat: mount session list pages in process instead of iframes

* fix: seed filter defaults from the query, not the cached search params

* fix: type trigger list rows from the generated trigger types

* fix: judge hosted lists by the operating workspace's rules and keep role-gated filters

* fix: keep the user folders filter key for every role, hidden where it does not apply

* fix: drop the user folders filter for users it is not offered to

* fix: wait for a known user before dropping the user folders filter

* style: tint trigger rows for every kind and align the schedules footer wording

* fix: stamp edited_at when a schedule is updated

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-09-24 15:14:59 +02:00

760 lines
24 KiB
Svelte

<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import {
Alert,
Badge,
Button,
EmptyState,
Skeleton,
Tab,
TabFade,
Tabs
} from '$lib/components/common'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import { OauthService, VariableService, WorkspaceService } from '$lib/gen'
import ContextualVariableEditor from '$lib/components/ContextualVariableEditor.svelte'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import Dropdown from '$lib/components/DropdownV2.svelte'
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import Popover from '$lib/components/Popover.svelte'
import FilterSearchbar, {
hasActiveFilters,
useUrlSyncedFilterInstance
} from '$lib/components/FilterSearchbar.svelte'
import { buildVariablesFilterSchema } from '$lib/components/variables/variablesFilter'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Cell from '$lib/components/table/Cell.svelte'
import DataTable from '$lib/components/table/DataTable.svelte'
import Head from '$lib/components/table/Head.svelte'
import Row from '$lib/components/table/Row.svelte'
import TableSimple from '$lib/components/TableSimple.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import VariableEditor from '$lib/components/VariableEditor.svelte'
import type { ContextualVariable, ListableVariable, WorkspaceDeployUISettings } from '$lib/gen'
import { enterpriseLicense, userWorkspaces } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { canWrite, isOwner, truncate } from '$lib/utils'
import { isDeployable, ALL_DEPLOYABLE } from '$lib/utils_deployable'
import {
Plus,
FileUp,
Link,
Pen,
RefreshCw,
Shield,
Trash,
Building,
DollarSign,
EyeOff,
Circle,
SearchX
} from 'lucide-svelte'
import { untrack } from 'svelte'
import { page } from '$app/stores'
import { useHostedPage } from '$lib/components/hostedPage'
import {
useOperatingWorkspace,
useOperatingUser
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const hosted = useHostedPage()
function editVariable(path: string) {
if (hosted) hosted.openItem(path)
else variableEditor?.editVariable(path)
}
type ListableVariableW = ListableVariable & { canWrite: boolean }
let variables = $state(undefined) as ListableVariableW[] | undefined
let showCreateButtons = $state(false)
// Collect unique values for filter autocomplete
let allPaths: string[] = $state([])
let allOwners: string[] = $state([])
let allLabels: string[] = $state([])
// FilterSearchbar setup
let userFoldersFilterType = $derived(
operatingUser.current?.is_super_admin && operatingUser.current.username.includes('@')
? 'only f/*'
: operatingUser.current?.is_admin || operatingUser.current?.is_super_admin
? 'u/username and f/*'
: undefined
)
let variablesFilterSchema = $derived(
buildVariablesFilterSchema({
paths: allPaths,
owners: allOwners,
labels: allLabels,
showUserFoldersFilter: userFoldersFilterType !== undefined,
userFoldersLabel:
userFoldersFilterType === 'only f/*'
? 'Only f/*'
: `Only u/${operatingUser.current?.username} and f/*`
})
)
let filters = useUrlSyncedFilterInstance(untrack(() => variablesFilterSchema))
// A link can carry the user folders filter to someone it is not offered to, where it would sit
// set with no chip to clear it. Dropped only once a user is actually known: `resolved` holds for
// the navigation workspace even while its user is still loading.
$effect(() => {
if (!operatingUser.resolved($operatingWorkspace) || !operatingUser.current) return
if (userFoldersFilterType !== undefined) return
if (!filters.val.user_folders_only) return
untrack(() => {
const { user_folders_only: _, ...rest } = filters.val
filters.val = rest
})
})
let itemFolders = $derived(
Array.from(
new Set(
(variables ?? [])
.map((x) => x.path.split('/').slice(0, 2).join('/'))
.filter((x) => x.startsWith('f/'))
)
)
.sort()
.map((f) => f.replace(/^f\//, ''))
)
let folderPresets = $derived([
...itemFolders.map((f) => ({ name: `f/${f}`, value: `path_start:\\ f/${f}/` })),
...allLabels.map((l) => ({ name: l, value: `label:\\ ${l}` })),
...(!variablesFilterSchema.user_folders_only.hidden
? [
{
name: variablesFilterSchema.user_folders_only.label ?? '?',
value: 'user_folders_only:\\ true'
}
]
: [])
])
let contextualVariables: ContextualVariable[] = $state([])
let shareModal: ShareModal | undefined = $state()
let variableEditor: VariableEditor | undefined = $state()
let contextualVariableEditor: ContextualVariableEditor | undefined = $state()
let loading = $state({
contextual: true
})
let deleteConfirmedCallback: (() => void) | undefined = $state(undefined)
let deleteIsLinked = $state(false)
let open = $derived(Boolean(deleteConfirmedCallback))
// Filter variables client-side for user folder filtering (admin feature)
let filteredItems = $derived.by(() => {
let items = variables
if (filters.val.user_folders_only && items) {
items = items.filter((item) => {
if (userFoldersFilterType === 'only f/*') return item.path.startsWith('f/')
if (userFoldersFilterType === 'u/username and f/*')
return (
item.path.startsWith('f/') ||
item.path.startsWith(`u/${operatingUser.current?.username}/`)
)
return true
})
}
return items
})
// If relative, the dropdown is positioned relative to its button
async function loadVariables(): Promise<void> {
const currentFilters = filters.val
// Build API parameters from filters.
// `includeDraftOnly` surfaces per-user drafts at paths that have
// no deployed variable — appended server-side when no narrowing
// filter is set, so the user sees AI-agent-created drafts on
// the home page.
const apiParams: any = {
workspace: $operatingWorkspace!,
includeDraftOnly: true
}
if (currentFilters.path) {
apiParams.path = currentFilters.path
}
if (currentFilters.path_start) {
apiParams.pathStart = currentFilters.path_start
}
if (currentFilters.description) {
apiParams.description = currentFilters.description
}
if (currentFilters.value) {
apiParams.value = currentFilters.value
}
if (currentFilters.owner) {
apiParams.pathStart = currentFilters.owner
}
if (currentFilters._default_) {
apiParams.broadFilter = currentFilters._default_
}
if (currentFilters.label) {
apiParams.label = currentFilters.label
}
const result = (await VariableService.listVariable(apiParams)).map((x) => {
return {
canWrite:
canWrite(x.path, x.extra_perms!, operatingUser.current) &&
x.workspace_id == $operatingWorkspace,
...x
}
})
// Extract unique values for autocomplete
allPaths = Array.from(new Set(result.map((x) => x.path))).sort()
allOwners = Array.from(
new Set(result.map((x) => x.path.split('/').slice(0, 2).join('/')))
).sort()
allLabels = Array.from(
new Set(result.flatMap((x) => [...(x.labels ?? []), ...(x.inherited_labels ?? [])]))
).sort()
variables = result
}
// Reload variables when filters change
$effect(() => {
filters.val
if ($operatingWorkspace) {
untrack(() => loadVariables())
}
})
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getPublicSettings({ workspace: $operatingWorkspace! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
async function loadContextualVariables(): Promise<void> {
contextualVariables = await VariableService.listContextualVariables({
workspace: $operatingWorkspace!
})
loading.contextual = false
}
async function deleteVariable(path: string, account?: number): Promise<void> {
if (account) {
OauthService.disconnectAccount({ workspace: $operatingWorkspace!, id: account })
}
await VariableService.deleteVariable({ workspace: $operatingWorkspace!, path })
loadVariables()
sendUserToast(`Variable ${path} was deleted`)
}
$effect(() => {
if ($operatingWorkspace && operatingUser.current) {
untrack(() => {
loadVariables()
loadContextualVariables()
})
}
})
let tab: 'workspace' | 'contextual' = $state('workspace')
let activeFilters = $derived(hasActiveFilters(filters.val))
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
async function deleteContextualVariable(row: { name: string }) {
await WorkspaceService.setEnvironmentVariable({
workspace: $operatingWorkspace!,
requestBody: {
name: row.name,
value: undefined
}
})
loadContextualVariables()
sendUserToast(
`Custom contextual variable ${row.name} was deleted. It may take up to a few minutes to update.`
)
setTimeout(() => {
loadContextualVariables()
}, 5000)
}
// Deep link: #<path> opens that variable's edit drawer. Reactive rather than
// onMount so a hash change on the already-mounted page (e.g. the AI session
// preview re-pointing its tab) opens the drawer too. Row links pre-set
// handledHash: their onclick already opens the drawer.
let handledHash = ''
$effect(() => {
// A host opens rows itself, and the document's hash is not this page's.
if (hosted) return
const hash = $page.url.hash
// Only item paths are drawer targets: the same hash also carries global
// drawers like #superadmin-settings, which must not be looked up as a variable.
if (!/^#[ufg]\//.test(hash)) {
// Navigating away from a drawer target must clear the tracker, or
// re-targeting the same item later would be skipped as already handled.
handledHash = ''
return
}
if (hash === handledHash || !variableEditor) return
handledHash = hash
variableEditor.editVariable(hash.slice(1))
})
</script>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.variables}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{:else}
<CenteredPage>
<PageHeader
title="Variables"
tooltip="Save and permission strings to be reused in Scripts and Flows."
documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets"
>
{#if showCreateButtons}
<div class="flex flex-row justify-end">
{#if tab == 'contextual' && (operatingUser.current?.is_admin || operatingUser.current?.is_super_admin)}
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => contextualVariableEditor?.initNew()}
>
New&nbsp;contextual&nbsp;variable
</Button>
{:else}
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => variableEditor?.initNew()}
>
New&nbsp;variable
</Button>
{/if}
</div>
{/if}
</PageHeader>
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
<VariableEditor
bind:this={variableEditor}
workspace={$operatingWorkspace}
on:create={loadVariables}
/>
<ContextualVariableEditor
bind:this={contextualVariableEditor}
on:update={loadContextualVariables}
/>
<ShareModal
bind:this={shareModal}
on:change={() => {
loadVariables()
}}
/>
<div class="flex gap-2 justify-between items-center">
<Tabs bind:selected={tab}>
<Tab value="workspace" label="Workspace" icon={Building} />
<Tab value="contextual" label="Contextual" icon={DollarSign}>
{#snippet extra()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets#contextual-variables"
>
Contextual variables are passed as environment variables when running a script and
depends on the execution context.
</Tooltip>
{/snippet}
</Tab>
</Tabs>
{#if tab == 'workspace'}
<FilterSearchbar
class="grow max-w-[26rem]"
schema={variablesFilterSchema}
bind:value={filters.val}
placeholder="Filter variables..."
presets={folderPresets}
/>
{/if}
</div>
<TabFade key={tab}>
{#if tab == 'workspace'}
<div class="relative overflow-x-auto pb-40 mt-4">
{#if !filteredItems}
<Skeleton layout={[0.5, [2], 1]} />
{#each new Array(3) as _}
<Skeleton layout={[[3.5], 0.5]} />
{/each}
{:else if filteredItems.length == 0}
{#if activeFilters}
<EmptyState
icon={SearchX}
title="No variables found"
description="No variable matches the current filters. Try clearing or widening them."
/>
{:else}
<EmptyState
icon={DollarSign}
title="No variables yet"
description="Variables store values and secrets that your scripts, flows and apps can read at runtime."
action={showCreateButtons
? {
label: 'Add a variable',
icon: Plus,
onClick: () => variableEditor?.initNew(),
aiId: 'variables-empty-add-variable',
aiDescription: 'Add variable'
}
: undefined}
/>
{/if}
{:else}
<DataTable size="xs">
<Head>
<tr>
<Cell head first class="!px-0" />
<Cell head>Path</Cell>
<Cell head>Value</Cell>
<Cell head>Description</Cell>
<Cell head>Status</Cell>
<Cell head last actions>Actions</Cell>
</tr>
</Head>
<tbody class="divide-y">
{#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft }}
{@const hasDraft =
getLocalDraftHint($operatingWorkspace, 'variable', path) ?? is_draft}
<Row>
<Cell class="!px-0 text-center w-12" first>
<SharedBadge {canWrite} extraPerms={extra_perms} />
</Cell>
<Cell>
<div class="flex items-center gap-2">
<a
class="break-all"
id="edit-{path}"
onclick={() => {
handledHash = `#${path}`
editVariable(path)
}}
href="#{path}"
>
{path}{hasDraft ? '*' : ''}
</a>
<DraftBadge {draft_only} is_draft={hasDraft} />
{#if labels?.length}
{#each labels as label}
<Badge
color="blue"
small
class="px-1"
title="Label: {label}"
clickable
onclick={() => {
const arr = (filters.val.label ?? '').split(',').filter(Boolean)
const idx = arr.indexOf(label)
if (idx >= 0) arr.splice(idx, 1)
else arr.push(label)
const newFilters = { ...filters.val }
if (arr.length) newFilters.label = arr.join(',')
else delete newFilters.label
filters.val = newFilters
}}>{label}</Badge
>
{/each}
{/if}
<InheritedLabels labels={inherited_labels} />
</div>
</Cell>
<Cell>
<span class="inline-flex flex-row items-center gap-2">
<div class="text-xs break-words">
{#if value}
{truncate(value, 20)}
{:else}
&lowast;&lowast;&lowast;&lowast;
{/if}
</div>
{#if is_secret}
<Popover notClickable>
<EyeOff size={12} />
{#snippet text()}
<span>This item is secret</span>
{/snippet}
</Popover>
{/if}
</span>
</Cell>
<Cell class="break-words">
<span class="text-xs text-primary">{truncate(description ?? '', 50)} </span>
</Cell>
<Cell class="text-center">
<div class="flex flex-row items-center gap-4">
{#if is_linked}
<Popover notClickable>
<Link size={16} />
{#snippet text()}
<div>
This variable is linked with a resource of the same path. They are
deleted and renamed together.
</div>
{/snippet}
</Popover>
{/if}
{#if account}
<Popover notClickable>
<RefreshCw size={16} />
{#snippet text()}
<div>
This OAuth token will be kept up-to-date in the background by
Windmill using its refresh token
</div>
{/snippet}
</Popover>
{/if}
{#if is_refreshed}
<div class="">
{#if refresh_error}
<Popover notClickable>
<!-- isolate: confine the ping indicator's z-50 to a local stacking context
so it can't paint over anything that scrolls past it -->
<div
class="relative inline-flex justify-center items-center w-4 h-4 isolate"
>
<Circle
class="text-red-600 animate-ping absolute z-50 w-4 h-4 fill-current"
size={12}
/>
<Circle
class="text-red-600 relative inline-flex fill-current "
size={12}
/>
</div>
{#snippet text()}
<div>
Latest exchange of the refresh token did not succeed. Error: {refresh_error}
</div>
{/snippet}
</Popover>
{:else if is_expired}
<Popover notClickable>
<Circle
class="text-yellow-600 animate-[pulse_5s_linear_infinite] fill-current"
size={12}
/>
{#snippet text()}
<div>
The access_token is expired, it will get renewed the next time
this variable is fetched or you can request is to be refreshed
in the dropdown on the right.
</div>
{/snippet}
</Popover>
{:else}
<Popover notClickable>
<Circle
class="text-green-600 animate-[pulse_5s_linear_infinite] fill-current"
size={12}
/>
{#snippet text()}
<div>
The variable was connected through OAuth and the token is not
expired.
</div>
{/snippet}
</Popover>
{/if}
</div>
{/if}
</div>
</Cell>
<Cell last actions shouldStopPropagation>
<Dropdown
items={() => {
let owner = isOwner(path, operatingUser.current, $operatingWorkspace)
return [
{
displayName: 'Edit',
icon: Pen,
action: () => editVariable(path),
disabled: !canWrite || !showCreateButtons
},
{
displayName: 'Delete',
icon: Trash,
type: 'delete',
action: (event) => {
if (event['shiftKey']) {
deleteVariable(path, account)
} else {
deleteIsLinked = is_linked ?? false
deleteConfirmedCallback = () => {
deleteVariable(path, account)
}
}
},
disabled: !owner || !showCreateButtons
},
...(!ws_specific &&
isDeployable(is_secret ? 'secret' : 'variable', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer?.openDrawer(path, 'variable')
}
}
]
: []),
{
displayName: 'Permissions',
action: () => {
shareModal?.openDrawer(path, 'variable')
},
icon: Shield
},
...(account != undefined
? [
{
displayName: 'Refresh token',
icon: RefreshCw,
action: async () => {
await OauthService.refreshToken({
workspace: $operatingWorkspace ?? '',
id: account ?? 0,
requestBody: {
path
}
})
sendUserToast('Token refreshed')
loadVariables()
}
}
]
: [])
]
}}
/>
</Cell>
</Row>
{/each}
</tbody>
</DataTable>
{/if}
</div>
{:else if tab == 'contextual'}
<div class="overflow-auto">
{#if loading.contextual}
<Skeleton layout={[0.5, [2], 1]} />
{#each new Array(8) as _}
<Skeleton layout={[[2.8], 0.5]} />
{/each}
{:else}
<PageHeader title="Custom contextual variables" primary={false} />
{#if contextualVariables.filter((x) => x.is_custom).length === 0}
<EmptyState
icon={DollarSign}
title="No custom contextual variables yet"
description="Custom contextual variables are passed as environment variables to every script run in this workspace."
action={showCreateButtons &&
(operatingUser.current?.is_admin || operatingUser.current?.is_super_admin)
? {
label: 'Add a contextual variable',
icon: Plus,
onClick: () => contextualVariableEditor?.initNew(),
aiId: 'variables-empty-add-contextual-variable',
aiDescription: 'Add contextual variable'
}
: undefined}
/>
{:else}
<TableSimple
headers={['Name', 'Value']}
data={contextualVariables.filter((x) => x.is_custom)}
keys={['name', 'value']}
getRowActions={operatingUser.current?.is_admin ||
operatingUser.current?.is_super_admin
? (row) => {
return [
{
displayName: 'Edit',
action: () => contextualVariableEditor?.editVariable(row.name, row.value)
},
{
displayName: 'Delete',
type: 'delete',
action: () => {
deleteContextualVariable(row)
}
}
]
}
: undefined}
/>
{/if}
<PageHeader title="Contextual variables" primary={false} />
<TableSimple
headers={['Name', 'Example of value', 'Description']}
data={contextualVariables.filter((x) => !x.is_custom)}
keys={['name', 'value', 'description']}
/>
{/if}
</div>
{/if}
</TabFade>
</CenteredPage>
{/if}
<ConfirmationModal
{open}
title="Remove variable"
confirmationText="Remove"
trashbin
on:canceled={() => {
deleteConfirmedCallback = undefined
}}
on:confirmed={() => {
if (deleteConfirmedCallback) {
deleteConfirmedCallback()
}
deleteConfirmedCallback = undefined
}}
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to remove this variable?</span>
{#if deleteIsLinked}
<Alert type="warning" title="Linked resource">
This variable is linked with a resource of the same path. The linked resource will also be
deleted.
</Alert>
{/if}
<Alert type="info" title="Bypass confirmation">
<div>
You can press
<Badge color="dark-gray">SHIFT</Badge>
while removing a variable to bypass confirmation.
</div>
</Alert>
</div>
</ConfirmationModal>