mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 00:02:13 +00:00
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
579 lines
18 KiB
Svelte
579 lines
18 KiB
Svelte
<script lang="ts">
|
|
import { createEventDispatcher, untrack } from 'svelte'
|
|
import { wsBase } from '$lib/workspaceUrl'
|
|
import { enterpriseLicense, superadmin, workspaceStore } from '$lib/stores'
|
|
import {
|
|
AppService,
|
|
FlowService,
|
|
ResourceService,
|
|
ScheduleService,
|
|
UserService,
|
|
WorkspaceService
|
|
} from '$lib/gen'
|
|
import { getAllModules } from './flows/flowExplorer'
|
|
import Button from './common/button/Button.svelte'
|
|
import Tooltip from './Tooltip.svelte'
|
|
import Alert from './common/alert/Alert.svelte'
|
|
import { DiffIcon, FileJson, Loader2 } from 'lucide-svelte'
|
|
import Badge from './common/badge/Badge.svelte'
|
|
import DiffDrawer from './DiffDrawer.svelte'
|
|
import {
|
|
getTriggerDependency,
|
|
type AdditionalInformation,
|
|
type Kind
|
|
} from '$lib/utils_deployable'
|
|
import {
|
|
checkItemExists,
|
|
deployItem,
|
|
getItemValue,
|
|
getOnBehalfOf
|
|
} from '$lib/utils_workspace_deploy'
|
|
import type { App } from './apps/types'
|
|
import { getAllGridItems } from './apps/editor/appUtils'
|
|
import { isRunnableByPath } from './apps/inputType'
|
|
import type { Runnable } from './raw_apps/utils'
|
|
import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte'
|
|
import OnBehalfOfSelector, {
|
|
needsOnBehalfOfSelection,
|
|
type OnBehalfOfChoice,
|
|
type OnBehalfOfDetails
|
|
} from './OnBehalfOfSelector.svelte'
|
|
import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte'
|
|
|
|
const dispatch = createEventDispatcher()
|
|
|
|
interface Props {
|
|
kind: Kind
|
|
initialPath?: string
|
|
additionalInformation?: AdditionalInformation | undefined
|
|
workspaceToDeployTo?: string | undefined
|
|
hideButton?: boolean
|
|
canDeployToWorkspace?: boolean
|
|
}
|
|
|
|
let {
|
|
kind,
|
|
initialPath = '',
|
|
additionalInformation = undefined,
|
|
workspaceToDeployTo = $bindable(undefined),
|
|
hideButton = false,
|
|
canDeployToWorkspace = $bindable(true)
|
|
}: Props = $props()
|
|
|
|
let canSeeTarget: 'yes' | 'cant-deploy-to-workspace' | 'cant-see-all-deps' | undefined =
|
|
$state(undefined)
|
|
|
|
type Dependency = { kind: Kind; path: string; include: boolean }
|
|
let dependencies: Dependency[] | undefined = $state<Dependency[] | undefined>(undefined)
|
|
|
|
const allAlreadyExists: { [key: string]: boolean } = $state({})
|
|
|
|
let diffDrawer: DiffDrawer | undefined = $state(undefined)
|
|
let notSet: boolean | undefined = $state(undefined)
|
|
let isFlow: boolean | undefined = $state(undefined)
|
|
|
|
// On-behalf-of tracking for flows and scripts
|
|
// Source workspace on_behalf_of emails (keyed by kind:path)
|
|
let sourceOnBehalfOfInfo = $state<Record<string, string | undefined>>({})
|
|
// Target workspace on_behalf_of emails (keyed by kind:path)
|
|
let targetOnBehalfOfInfo = $state<Record<string, string | undefined>>({})
|
|
let onBehalfOfChoice = $state<Record<string, OnBehalfOfChoice>>({})
|
|
let customOnBehalfOf = $state<Record<string, OnBehalfOfDetails>>({})
|
|
let canPreserveOnBehalfOf = $state(false)
|
|
|
|
// Check if an item needs on_behalf_of selection
|
|
function itemNeedsOnBehalfOfSelection(statusPath: string, kind: string): boolean {
|
|
return needsOnBehalfOfSelection(kind, sourceOnBehalfOfInfo[statusPath])
|
|
}
|
|
|
|
/**
|
|
* Get the on_behalf_of value for deployment based on user's choice.
|
|
* Returns an email for flows/scripts/apps, or permissioned_as (u/username, g/group) for triggers/schedules.
|
|
*/
|
|
function getOnBehalfOfForDeploy(statusPath: string, kind: Kind): string | undefined {
|
|
const choice = onBehalfOfChoice[statusPath]
|
|
if (choice === 'target') return targetOnBehalfOfInfo[statusPath]
|
|
if (choice === 'custom') {
|
|
const details = customOnBehalfOf[statusPath]
|
|
return kind === 'trigger' ? details?.permissionedAs : details?.email
|
|
}
|
|
// 'me' or undefined = don't pass, backend will use deploying user's identity
|
|
return undefined
|
|
}
|
|
|
|
async function reload(path: string) {
|
|
try {
|
|
if (!$superadmin) {
|
|
const targetUser = await UserService.whoami({ workspace: workspaceToDeployTo! })
|
|
canPreserveOnBehalfOf =
|
|
targetUser.is_admin || targetUser.groups?.includes('wm_deployers') || false
|
|
} else {
|
|
canPreserveOnBehalfOf = true
|
|
}
|
|
canSeeTarget = 'yes'
|
|
} catch {
|
|
canSeeTarget = 'cant-deploy-to-workspace'
|
|
canPreserveOnBehalfOf = false
|
|
return
|
|
}
|
|
|
|
let allDeps
|
|
try {
|
|
allDeps = await getDependencies(kind, path)
|
|
} catch {
|
|
canSeeTarget = 'cant-see-all-deps'
|
|
return
|
|
}
|
|
|
|
let sortedSet: { kind: Kind; path: string }[] = []
|
|
allDeps.forEach((x) => {
|
|
if (!sortedSet.find((y) => y.kind == x.kind && y.path == x.path)) {
|
|
sortedSet.push(x)
|
|
}
|
|
})
|
|
for (const dep of sortedSet) {
|
|
allAlreadyExists[computeStatusPath(dep.kind, dep.path)] = await checkAlreadyExists(
|
|
dep.kind,
|
|
dep.path
|
|
)
|
|
}
|
|
dependencies = sortedSet.map((x) => ({
|
|
...x,
|
|
include:
|
|
x.kind != 'variable' &&
|
|
x.kind != 'resource' &&
|
|
x.kind != 'resource_type' &&
|
|
(x.kind != 'folder' || !allAlreadyExists[computeStatusPath(x.kind, x.path)])
|
|
}))
|
|
|
|
// Fetch on_behalf_of_email for flows, scripts, apps, and triggers from both workspaces
|
|
for (const dep of sortedSet.filter((d) =>
|
|
['flow', 'script', 'app', 'trigger'].includes(d.kind)
|
|
)) {
|
|
const key = computeStatusPath(dep.kind, dep.path)
|
|
try {
|
|
sourceOnBehalfOfInfo[key] = await getOnBehalfOf(
|
|
dep.kind,
|
|
dep.path,
|
|
$workspaceStore!,
|
|
additionalInformation
|
|
)
|
|
} catch {
|
|
sourceOnBehalfOfInfo[key] = undefined
|
|
}
|
|
try {
|
|
targetOnBehalfOfInfo[key] = await getOnBehalfOf(
|
|
dep.kind,
|
|
dep.path,
|
|
workspaceToDeployTo!,
|
|
additionalInformation
|
|
)
|
|
} catch {
|
|
targetOnBehalfOfInfo[key] = undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
async function getDependencies(
|
|
kind: Kind,
|
|
path: string
|
|
): Promise<{ kind: Kind; path: string }[]> {
|
|
async function rec(kind: Kind, path: string): Promise<{ kind: Kind; path: string }[]> {
|
|
if (kind == 'schedule') {
|
|
const schedule = await ScheduleService.getSchedule({ workspace: $workspaceStore!, path })
|
|
if (schedule.script_path && schedule.script_path != '') {
|
|
if (schedule.script_path) {
|
|
return [{ kind: 'script', path: schedule.script_path }]
|
|
} else {
|
|
return [{ kind: 'flow', path: schedule.script_path }]
|
|
}
|
|
} else {
|
|
return []
|
|
}
|
|
} else if (kind == 'flow') {
|
|
const flow = await FlowService.getFlowByPath({ workspace: $workspaceStore!, path })
|
|
return getAllModules(flow.value.modules, flow.value.failure_module).flatMap((x) => {
|
|
let result: { kind: Kind; path: string }[] = []
|
|
if (x.value.type == 'script' || x.value.type == 'rawscript' || x.value.type == 'flow') {
|
|
Object.values(x.value.input_transforms).forEach((y) => {
|
|
if (y.type == 'static' && typeof y.value == 'string') {
|
|
if (y.value.startsWith('$res:')) {
|
|
result.push({ kind: 'resource', path: y.value.substring(5) })
|
|
} else if (y.value.startsWith('$var:')) {
|
|
result.push({ kind: 'variable', path: y.value.substring(5) })
|
|
}
|
|
}
|
|
})
|
|
}
|
|
if (x.value.type == 'script') {
|
|
if (x.value.path && !x.value.path.startsWith('hub/')) {
|
|
result.push({ kind: 'script', path: x.value.path })
|
|
}
|
|
} else if (x.value.type == 'flow') {
|
|
if (x.value.path) {
|
|
result.push({ kind: 'flow', path: x.value.path })
|
|
}
|
|
}
|
|
return result
|
|
})
|
|
} else if (kind == 'app') {
|
|
const app = await AppService.getAppByPath({ workspace: $workspaceStore!, path })
|
|
let result: { kind: Kind; path: string }[] = []
|
|
if (app.raw_app) {
|
|
const rawAppValue = app.value as { runnables?: Record<string, Runnable> }
|
|
for (const runnable of Object.values(rawAppValue.runnables ?? {})) {
|
|
if (isRunnableByPath(runnable)) {
|
|
if (runnable.runType == 'script') {
|
|
result.push({ kind: 'script', path: runnable.path })
|
|
} else if (runnable.runType == 'flow') {
|
|
result.push({ kind: 'flow', path: runnable.path })
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
let appValue = app.value as App
|
|
getAllGridItems(appValue).forEach((gridItem) => {
|
|
const ci = gridItem.data.componentInput
|
|
if (ci?.type == 'runnable' && isRunnableByPath(ci.runnable)) {
|
|
if (ci.runnable.runType == 'script') {
|
|
result.push({ kind: 'script', path: ci.runnable.path })
|
|
} else if (ci.runnable.runType == 'flow') {
|
|
result.push({ kind: 'flow', path: ci.runnable.path })
|
|
}
|
|
}
|
|
})
|
|
}
|
|
return result
|
|
} else if (kind == 'resource') {
|
|
const res = await ResourceService.getResource({ workspace: $workspaceStore!, path })
|
|
function recObj(obj: any) {
|
|
if (typeof obj == 'string' && obj.startsWith('$var:')) {
|
|
return [{ kind: 'variable', path: obj.substring(5) }]
|
|
} else if (typeof obj == 'object') {
|
|
return Object.values(obj).flatMap((x) => recObj(x))
|
|
} else {
|
|
return []
|
|
}
|
|
}
|
|
|
|
return [...recObj(res.value), { kind: 'resource_type', path: res.resource_type }]
|
|
} else if (kind == 'trigger') {
|
|
if (additionalInformation?.triggers) {
|
|
return getTriggerDependency(additionalInformation.triggers.kind, path, $workspaceStore!)
|
|
}
|
|
throw new Error('Missing trigger information')
|
|
}
|
|
return []
|
|
}
|
|
let toProcess = [{ kind, path }]
|
|
let processed: { kind: Kind; path: string }[] = []
|
|
while (toProcess.length > 0) {
|
|
const { kind, path } = toProcess.pop()!
|
|
toProcess.push(...(await rec(kind, path)))
|
|
processed.push({ kind, path })
|
|
}
|
|
let folders: string[] = []
|
|
for (const p of processed) {
|
|
let split = p.path.split('/')
|
|
if (split.length > 2 && split[0] == 'f' && !folders.includes(split[1])) {
|
|
folders.push(split[1])
|
|
processed.push({ kind: 'folder', path: split[1] })
|
|
}
|
|
}
|
|
return processed
|
|
}
|
|
|
|
async function checkAlreadyExists(kind: Kind, path: string): Promise<boolean> {
|
|
return checkItemExists(kind, path, workspaceToDeployTo!, additionalInformation)
|
|
}
|
|
|
|
const deploymentStatus: Record<
|
|
string,
|
|
{ status: 'loading' | 'deployed' | 'failed'; error?: string }
|
|
> = $state({})
|
|
|
|
async function deploy(kind: Kind, path: string) {
|
|
const statusPath = computeStatusPath(kind, path)
|
|
deploymentStatus[statusPath] = { status: 'loading' }
|
|
|
|
const result = await deployItem({
|
|
kind,
|
|
path,
|
|
workspaceFrom: $workspaceStore!,
|
|
workspaceTo: workspaceToDeployTo!,
|
|
additionalInformation,
|
|
onBehalfOf: getOnBehalfOfForDeploy(statusPath, kind)
|
|
})
|
|
|
|
if (result.success) {
|
|
allAlreadyExists[statusPath] = true
|
|
deploymentStatus[statusPath] = { status: 'deployed' }
|
|
} else {
|
|
deploymentStatus[statusPath] = { status: 'failed', error: result.error }
|
|
}
|
|
}
|
|
|
|
export function deployAll() {
|
|
dependencies?.slice().forEach(async ({ kind, path, include }) => {
|
|
if (include) {
|
|
await deploy(kind, path)
|
|
}
|
|
})
|
|
dispatch('update', initialPath)
|
|
}
|
|
|
|
function computeStatusPath(kind: Kind, path: string) {
|
|
return `${kind}:${path}`
|
|
}
|
|
|
|
async function showDiff(kind: Kind, path: string) {
|
|
diffDrawer?.openDrawer()
|
|
let values = await Promise.all([
|
|
getItemValue(kind, path, workspaceToDeployTo!, additionalInformation),
|
|
getItemValue(kind, path, $workspaceStore!, additionalInformation)
|
|
])
|
|
diffDrawer?.setDiff({
|
|
mode: 'simple',
|
|
original: values?.[0] as any,
|
|
current: values?.[1] as any,
|
|
title: 'Staging/prod <> Dev'
|
|
})
|
|
}
|
|
|
|
$effect(() => {
|
|
WorkspaceService.getDeployTo({ workspace: $workspaceStore! }).then((x) => {
|
|
workspaceToDeployTo = x.deploy_to
|
|
if (x.deploy_to == undefined) {
|
|
notSet = true
|
|
}
|
|
})
|
|
})
|
|
|
|
$effect(() => {
|
|
workspaceToDeployTo && initialPath && untrack(() => reload(initialPath))
|
|
})
|
|
|
|
// Transform dependencies to deployable item format for the shared layout
|
|
let deployableItems = $derived(
|
|
(dependencies ?? []).map((dep) => ({
|
|
key: computeStatusPath(dep.kind, dep.path),
|
|
path: dep.path,
|
|
kind: dep.kind,
|
|
include: dep.include,
|
|
triggerKind: dep.kind === 'trigger' ? additionalInformation?.triggers?.kind : undefined
|
|
}))
|
|
)
|
|
|
|
let selectedItems = $derived<string[]>(
|
|
(dependencies ?? [])
|
|
.filter((dep) => dep.include)
|
|
.map((dep) => computeStatusPath(dep.kind, dep.path))
|
|
)
|
|
|
|
let allSelected = $derived(
|
|
dependencies != null && dependencies.length > 0 && dependencies.every((dep) => dep.include)
|
|
)
|
|
|
|
// Check if all required on_behalf_of selections are made
|
|
let hasUnselectedOnBehalfOf = $derived(
|
|
selectedItems.some((statusPath) => {
|
|
const dep = dependencies?.find((d) => computeStatusPath(d.kind, d.path) === statusPath)
|
|
if (!dep) return false
|
|
return (
|
|
itemNeedsOnBehalfOfSelection(statusPath, dep.kind) &&
|
|
onBehalfOfChoice[statusPath] === undefined
|
|
)
|
|
})
|
|
)
|
|
|
|
function toggleItem(item: { key: string }) {
|
|
if (dependencies) {
|
|
const idx = dependencies.findIndex(
|
|
(dep) => computeStatusPath(dep.kind, dep.path) === item.key
|
|
)
|
|
if (idx !== -1) {
|
|
dependencies[idx].include = !dependencies[idx].include
|
|
}
|
|
}
|
|
}
|
|
|
|
function selectAll() {
|
|
if (dependencies) {
|
|
dependencies = dependencies.map((dep) => ({ ...dep, include: true }))
|
|
}
|
|
}
|
|
|
|
function deselectAll() {
|
|
if (dependencies) {
|
|
dependencies = dependencies.map((dep) => ({ ...dep, include: false }))
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="mt-6"></div>
|
|
|
|
{#if !$enterpriseLicense}
|
|
<Alert type="warning" title="Enterprise license required"
|
|
>Deploy to staging/prod from the web UI is only available with an enterprise license</Alert
|
|
>
|
|
{:else if notSet == true}
|
|
<Alert type="error" title="Staging/Prod deploy not set up"
|
|
>As an admin, go to Settings {'->'} Workspace {'->'} Deployment UI</Alert
|
|
>
|
|
{:else}
|
|
<Alert type="info" title="Shareable page"
|
|
>Share this <a href="{$wsBase}/deploy/{kind}/{initialPath}">link</a> to have another properly permissioned
|
|
user do the deployment</Alert
|
|
>
|
|
|
|
<h3 class="mb-2 mt-8"
|
|
>Destination Workspace <Tooltip
|
|
>Workspace to deploy to is set in the workspace settings</Tooltip
|
|
></h3
|
|
>
|
|
<input class="max-w-xs" type="text" disabled value={workspaceToDeployTo} />
|
|
|
|
{#if workspaceToDeployTo}
|
|
<ParentWorkspaceProtectionAlert
|
|
parentWorkspaceId={workspaceToDeployTo}
|
|
onUpdateCanDeploy={(canDeploy) => {
|
|
canDeployToWorkspace = canDeploy
|
|
}}
|
|
/>
|
|
{/if}
|
|
|
|
{#if canSeeTarget == undefined}
|
|
<div class="mt-6"></div>
|
|
<Loader2 class="animate-spin" />
|
|
{:else if canSeeTarget == 'yes'}
|
|
<h3 class="mb-6 mt-16">All related deployable items</h3>
|
|
|
|
<DiffDrawer bind:this={diffDrawer} {isFlow} />
|
|
|
|
<WorkspaceDeployLayout
|
|
items={deployableItems}
|
|
{selectedItems}
|
|
{deploymentStatus}
|
|
selectablePredicate={() => true}
|
|
{allSelected}
|
|
onToggleItem={toggleItem}
|
|
onSelectAll={selectAll}
|
|
onDeselectAll={deselectAll}
|
|
emptyMessage="No deployable items found"
|
|
>
|
|
{#snippet itemActions(item)}
|
|
{@const statusPath = item.key}
|
|
{@const exists = allAlreadyExists[statusPath]}
|
|
{@const status = deploymentStatus[statusPath]}
|
|
{@const targetValue = targetOnBehalfOfInfo[statusPath]}
|
|
|
|
<!-- On-behalf-of selector -->
|
|
{#if itemNeedsOnBehalfOfSelection(statusPath, item.kind)}
|
|
<OnBehalfOfSelector
|
|
targetWorkspace={workspaceToDeployTo!}
|
|
{targetValue}
|
|
selected={onBehalfOfChoice[statusPath]}
|
|
onSelect={(choice, details) => {
|
|
onBehalfOfChoice[statusPath] = choice
|
|
if (details) customOnBehalfOf[statusPath] = details
|
|
}}
|
|
kind={item.kind}
|
|
canPreserve={canPreserveOnBehalfOf}
|
|
customValue={customOnBehalfOf[statusPath]?.permissionedAs}
|
|
/>
|
|
{/if}
|
|
|
|
{#if item.kind === 'raw_app'}
|
|
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
|
|
{/if}
|
|
{#if exists === false}
|
|
{#if item.include}
|
|
<Badge
|
|
>New <Tooltip
|
|
>This {item.kind} doesn't exist yet on the target and will be created by the deployment</Tooltip
|
|
></Badge
|
|
>
|
|
{:else}
|
|
<Badge color="red">
|
|
Missing
|
|
<Tooltip
|
|
>{#if item.kind == 'resource_type'}
|
|
Resource types are not re-deployed by default. We strongly recommend to add shared
|
|
resource types in 'admin' workspace, which will have them be shared to every
|
|
workspace.
|
|
{:else}
|
|
This {item.kind} doesn't exist and is not included in the deployment. Variables and
|
|
Resources are considered to be workspace specific and are never included by default.
|
|
{/if}</Tooltip
|
|
>
|
|
</Badge>
|
|
{/if}
|
|
{:else if exists === true && !status}
|
|
<Button
|
|
size="xs"
|
|
variant="subtle"
|
|
onclick={() => {
|
|
showDiff(item.kind, item.path)
|
|
isFlow = item.kind === 'flow'
|
|
}}
|
|
>
|
|
<DiffIcon class="w-3 h-3" />
|
|
Show diff
|
|
</Button>
|
|
{/if}
|
|
|
|
{#if !status}
|
|
<Button
|
|
color="light"
|
|
size="xs"
|
|
disabled={!canDeployToWorkspace ||
|
|
(itemNeedsOnBehalfOfSelection(statusPath, item.kind) &&
|
|
onBehalfOfChoice[statusPath] === undefined)}
|
|
onclick={() => deploy(item.kind, item.path)}>Deploy</Button
|
|
>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet footer()}
|
|
<div class="flex flex-col items-end gap-2">
|
|
{#if !hideButton}
|
|
<Button on:click={deployAll} disabled={!canDeployToWorkspace || hasUnselectedOnBehalfOf}
|
|
>Deploy all toggled</Button
|
|
>
|
|
{/if}
|
|
{#if hasUnselectedOnBehalfOf}
|
|
<span class="text-xs text-yellow-600">
|
|
{#if kind === 'trigger'}
|
|
You must set the "permissioned as" user for all triggers before deploying
|
|
<Tooltip class="text-yellow-600">
|
|
The "permissioned as" field defines which user's permissions will be applied when
|
|
the trigger fires. Make sure this is set appropriately before deploying.
|
|
</Tooltip>
|
|
{:else}
|
|
You must set the "on behalf of" user for all items before deploying
|
|
<Tooltip class="text-yellow-600">
|
|
The "run on behalf of" field defines which user's permissions will be applied
|
|
during execution. Make sure this is set to an appropriate user before deploying.
|
|
</Tooltip>
|
|
{/if}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{/snippet}
|
|
</WorkspaceDeployLayout>
|
|
{:else if canSeeTarget == 'cant-see-all-deps'}
|
|
<div class="my-2"></div>
|
|
<Alert type="error" title="User doesn't have visibility over all dependencies"
|
|
>You do not have visibility over some of the dependencies of this item. Ask a permissioned
|
|
user to deploy this item using the shareable link or get the proper permissions on the
|
|
dependencies</Alert
|
|
>
|
|
{:else}
|
|
<div class="my-2"></div>
|
|
<Alert type="error" title="User not allowed to deploy to this workspace"
|
|
>Ask a permissioned user to deploy this item using the shareable link or get the proper
|
|
permissions on the target workspace</Alert
|
|
>
|
|
{/if}
|
|
{/if}
|