mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-22 00:03:35 +00:00
* 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>
760 lines
24 KiB
Svelte
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 contextual variable
|
|
</Button>
|
|
{:else}
|
|
<Button
|
|
unifiedSize="md"
|
|
variant="accent"
|
|
startIcon={{ icon: Plus }}
|
|
on:click={() => variableEditor?.initNew()}
|
|
>
|
|
New 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}
|
|
∗∗∗∗
|
|
{/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>
|