mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
create_fork copies the whole workspace inside the request, which can run past the route timeout of an ingress in front of Windmill (Envoy's 15s default), and the UI then reports a failure for a fork still being made. create_fork?background=true now returns once the request is validated and records the copy in workspace_fork_creation, which the new fork_creation_status endpoint reads. The wizard and AI-session forks use it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1143 lines
40 KiB
Svelte
1143 lines
40 KiB
Svelte
<script lang="ts">
|
|
import { run } from 'svelte/legacy'
|
|
|
|
import {
|
|
JobService,
|
|
ResourceService,
|
|
UserService,
|
|
VariableService,
|
|
WorkspaceService,
|
|
type AIProvider,
|
|
type CompletedJob
|
|
} from '$lib/gen'
|
|
import { validateUsername } from '$lib/utils'
|
|
import { logoutWithRedirect } from '$lib/logoutKit'
|
|
import { page } from '$app/state'
|
|
import {
|
|
enterpriseLicense,
|
|
superadmin,
|
|
usersWorkspaceStore,
|
|
userWorkspaces,
|
|
workspaceStore
|
|
} from '$lib/stores'
|
|
import {
|
|
workspaceIsFork,
|
|
findWorkspaceRoot,
|
|
findWorkspaceDescendants,
|
|
findDefaultForkBase,
|
|
devWorkspacesInChainAbove
|
|
} from '$lib/utils/workspaceHierarchy'
|
|
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
|
|
import {
|
|
fetchProtectionRulesForWorkspace,
|
|
isRuleUnconditionallyActiveInRulesets
|
|
} from '$lib/workspaceProtectionRules.svelte'
|
|
import { resource } from 'runed'
|
|
import { Button } from '$lib/components/common'
|
|
import {
|
|
DEV_WORKSPACE_LABELS,
|
|
devBadgeText,
|
|
devLabelKey,
|
|
type DevWorkspaceLabelKey
|
|
} from '$lib/utils/devWorkspaceLabel'
|
|
import DevWorkspaceLabelPicker from '$lib/components/workspaceSettings/DevWorkspaceLabelPicker.svelte'
|
|
import Toggle from '$lib/components/Toggle.svelte'
|
|
import Tooltip from '$lib/components/Tooltip.svelte'
|
|
import { onMount } from 'svelte'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import TestAIKey from '$lib/components/copilot/TestAIKey.svelte'
|
|
import {
|
|
enterNewWorkspace,
|
|
loadUsernamePolicy,
|
|
refreshWorkspaceList
|
|
} from '$lib/workspaceCreation'
|
|
import { validateWorkspaceId } from '$lib/utils/workspaceId'
|
|
import { deleteSessionsForWorkspace } from '$lib/components/sessions/sessionState.svelte'
|
|
import { isCloudHosted } from '$lib/cloud'
|
|
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
|
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
|
import { AI_PROVIDERS } from '$lib/components/copilot/lib'
|
|
import { LoaderCircle, Trash2 } from 'lucide-svelte'
|
|
import PrefixedInput from '../PrefixedInput.svelte'
|
|
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
|
import TextInput from '../text_input/TextInput.svelte'
|
|
import { jobManager } from '$lib/services/JobManager'
|
|
import Alert from '../common/alert/Alert.svelte'
|
|
import { base } from '$lib/base'
|
|
import Label from '../Label.svelte'
|
|
import ForkDatatableSection from './ForkDatatableSection.svelte'
|
|
import Select from '../select/Select.svelte'
|
|
import WorkspaceScopeTrigger from '../WorkspaceScopeTrigger.svelte'
|
|
import DarkModeToggle from '../sidebar/DarkModeToggle.svelte'
|
|
import ForkDucklakeSection from './ForkDucklakeSection.svelte'
|
|
import { createWorkspaceForkAndWait } from '$lib/utils/forkCreation'
|
|
|
|
interface Props {
|
|
isFork?: boolean
|
|
// Rendered inside the global fork modal rather than the standalone
|
|
// /user/create_workspace page: content-driven height and no
|
|
// "Back to workspaces" navigation.
|
|
inModal?: boolean
|
|
onFinish?: () => void
|
|
}
|
|
|
|
let { isFork = false, inModal = false, onFinish }: Props = $props()
|
|
|
|
// Dev-workspace mode: create the fork as a persistent, prefix-less dev workspace and (optionally)
|
|
// lock the parent ("prod") against direct edits.
|
|
let createAsDevWorkspace = $state(false)
|
|
let lockProdDeploy = $state(true)
|
|
let lockProdForking = $state(true)
|
|
// Bring the parent's members into the fork (a shared env). Defaults on for a
|
|
// dev workspace, off for a throwaway fork; flipping the dev toggle resets it.
|
|
let copyMembers = $state(false)
|
|
$effect(() => {
|
|
copyMembers = createAsDevWorkspace
|
|
})
|
|
|
|
// A dev workspace can only be created off a root or dev base (backend rejects a dev of a throwaway
|
|
// fork). Clear a stale toggle when the base no longer qualifies so we never submit
|
|
// is_dev_workspace against a fork.
|
|
$effect(() => {
|
|
if (!canDesignateDevWorkspace && createAsDevWorkspace) {
|
|
createAsDevWorkspace = false
|
|
}
|
|
})
|
|
// "Create a new dev workspace" links here with ?dev=true, so the button creates what it names.
|
|
// Applied once eligibility is confirmed rather than at init, since the effect above would clear a
|
|
// toggle set while the server check is still in flight.
|
|
let devRequestedFromUrl = $state(page.url.searchParams.get('dev') === 'true')
|
|
$effect(() => {
|
|
if (devRequestedFromUrl && canDesignateDevWorkspace) {
|
|
devRequestedFromUrl = false
|
|
createAsDevWorkspace = true
|
|
}
|
|
})
|
|
|
|
// The base workspace to fork from. A fork's git branch is based on its parent's branch, so picking
|
|
// a fork here (rather than the root) yields a fork of a fork.
|
|
let baseWorkspaceId = $state<string | undefined>(undefined)
|
|
|
|
// Base list for forking, folding in a superadmin-visited workspace (see useForkableWorkspaces).
|
|
const forkable = useForkableWorkspaces({
|
|
workspaces: () => $userWorkspaces,
|
|
currentWorkspaceId: () => $workspaceStore,
|
|
isSuperadmin: () => !!$superadmin,
|
|
enabled: () => isFork
|
|
})
|
|
let forkableWorkspaces = $derived(forkable.current)
|
|
|
|
// Base candidates are the current workspace's family: its root first, then every fork/dev under it.
|
|
let familyRoot = $derived(findWorkspaceRoot($workspaceStore, forkableWorkspaces))
|
|
let baseCandidates = $derived(
|
|
familyRoot ? [familyRoot, ...findWorkspaceDescendants(familyRoot.id, forkableWorkspaces)] : []
|
|
)
|
|
let baseItems = $derived(
|
|
baseCandidates.map((w) => ({
|
|
value: w.id,
|
|
label: w.id === familyRoot?.id ? `${w.name} (root)` : w.name,
|
|
subtitle: w.is_dev_workspace ? 'dev workspace' : w.id === familyRoot?.id ? undefined : 'fork'
|
|
}))
|
|
)
|
|
let defaultBaseWorkspaceId = $derived(
|
|
findDefaultForkBase($workspaceStore, forkableWorkspaces)?.id
|
|
)
|
|
// Seed the base once the family is known; keep an explicit user choice as long as it stays valid.
|
|
$effect(() => {
|
|
if (!isFork) return
|
|
if (baseWorkspaceId && baseCandidates.some((w) => w.id === baseWorkspaceId)) return
|
|
baseWorkspaceId = defaultBaseWorkspaceId
|
|
})
|
|
|
|
// The dev-workspace option is only offered when forking a workspace that doesn't already have a
|
|
// dev: a workspace gets at most one. The base may be a root or, less conventionally, another dev
|
|
// workspace (a dev of a dev) — only a throwaway fork can't host one.
|
|
let baseWorkspaceEntry = $derived(forkableWorkspaces.find((w) => w.id === baseWorkspaceId))
|
|
// Require the base workspace to be loaded before treating it as eligible: a missing entry must not
|
|
// read as a root (it would offer invalid dev creation while the workspace list is still loading).
|
|
// `workspaceIsFork` (prefix OR parent) also excludes an orphaned `wm-fork-` workspace, whose parent
|
|
// FK was set null — it has no parent but is still a fork, so it can't host a dev workspace.
|
|
let baseCanHostDev = $derived(
|
|
!!baseWorkspaceEntry &&
|
|
(!workspaceIsFork(baseWorkspaceId, forkableWorkspaces) ||
|
|
!!baseWorkspaceEntry.is_dev_workspace)
|
|
)
|
|
const baseForkMembersResource = resource(
|
|
() => (isFork ? baseWorkspaceId : undefined),
|
|
async (ws, _prev, { signal }) => {
|
|
if (!ws) return undefined
|
|
const settings = await WorkspaceService.getPublicSettings({ workspace: ws })
|
|
if (signal.aborted) throw new DOMException('superseded', 'AbortError')
|
|
return { ws, adds: settings.add_admins_and_developers_to_forks }
|
|
}
|
|
)
|
|
let baseAddsAdminsAndDevelopers = $derived(
|
|
baseForkMembersResource.current?.ws === baseWorkspaceId &&
|
|
!!baseForkMembersResource.current?.adds
|
|
)
|
|
// Ask the server whether a dev already exists: the caller may not be a member of this prod's dev,
|
|
// so the client workspace list can't see it and would offer an invalid "create dev" action.
|
|
const devWorkspaceResource = resource(
|
|
() => (baseCanHostDev ? baseWorkspaceId : undefined),
|
|
async (ws) => (ws ? await WorkspaceService.getDevWorkspace({ workspace: ws }) : undefined)
|
|
)
|
|
// The label names the deploy branch, and the dev workspaces the new one would share a chain with
|
|
// hold theirs: the picker steers away from those rather than offering a choice the backend rejects.
|
|
let chainTakenLabels = $derived(
|
|
new Set(
|
|
devWorkspacesInChainAbove(baseWorkspaceId, forkableWorkspaces).map((w) =>
|
|
devLabelKey(w.dev_workspace_label)
|
|
)
|
|
)
|
|
)
|
|
let availableDevLabels = $derived(DEV_WORKSPACE_LABELS.filter((l) => !chainTakenLabels.has(l)))
|
|
// Offer dev designation only once the server confirms there's no dev yet (returns null); stay
|
|
// conservative (no offer) while the check is loading (current is undefined). With every label
|
|
// taken the chain is full and there is nothing left to designate.
|
|
let canDesignateDevWorkspace = $derived(
|
|
baseCanHostDev && availableDevLabels.length > 0 && devWorkspaceResource.current === null
|
|
)
|
|
|
|
let devWorkspaceLabel = $state<DevWorkspaceLabelKey>('dev')
|
|
$effect(() => {
|
|
if (!createAsDevWorkspace) devWorkspaceLabel = 'dev'
|
|
})
|
|
let currentWorkspaceName = $derived(
|
|
baseWorkspaceEntry?.name ?? baseWorkspaceId ?? 'the base workspace'
|
|
)
|
|
|
|
// If the root already blocks direct deploy / forking through an existing protection rule, keep the
|
|
// matching lock toggle on but locked: this flow only manages its own reserved dev-workspace rule, so
|
|
// turning it "off" here couldn't lift a separately-defined block. On a failed fetch we fall back to the
|
|
// editable default-on toggle, which can't drop protection (any real rule still enforces server-side).
|
|
const rootProtectionRules = resource(
|
|
() => (canDesignateDevWorkspace && createAsDevWorkspace ? baseWorkspaceId : undefined),
|
|
async (ws, _prev, { signal }) => {
|
|
if (!ws) return undefined
|
|
const rules = await fetchProtectionRulesForWorkspace(ws)
|
|
// The generated client can't take an abort signal, so drop a superseded response here: a late
|
|
// result for a previous base must not overwrite the newly selected base's rules.
|
|
if (signal.aborted) throw new DOMException('superseded', 'AbortError')
|
|
return { ws, rules }
|
|
}
|
|
)
|
|
// Only trust a result that belongs to the currently selected base (guards the in-flight window and
|
|
// any out-of-order response); undefined means "not known yet" and is treated as locked below.
|
|
let rootRules = $derived.by(() => {
|
|
const current = rootProtectionRules.current
|
|
return current && current.ws === baseWorkspaceId ? current.rules : undefined
|
|
})
|
|
// Only a rule with no bypass users/groups matches the empty-bypass reserved lock we would create; a
|
|
// bypassable rule stays editable, otherwise forcing the lock on would revoke the bypassed users'
|
|
// direct-deploy / forking access.
|
|
let rootAlreadyBlocksDeploy = $derived(
|
|
isRuleUnconditionallyActiveInRulesets(rootRules ?? [], 'DisableDirectDeployment')
|
|
)
|
|
let rootAlreadyBlocksForking = $derived(
|
|
isRuleUnconditionallyActiveInRulesets(rootRules ?? [], 'DisableWorkspaceForking')
|
|
)
|
|
// Until the fetch resolves for the selected base its rules are unknown. Treat each lock as engaged
|
|
// during that window so the toggle is locked on and the effective value stays true: otherwise a user
|
|
// could turn a lock off and submit before an existing rule is detected, sending false and omitting
|
|
// the reserved rule — leaving prod unprotected if that existing rule is later removed.
|
|
let rootRulesUnknown = $derived(rootProtectionRules.loading || rootRules === undefined)
|
|
let deployLocked = $derived(rootAlreadyBlocksDeploy || rootRulesUnknown)
|
|
let forkingLocked = $derived(rootAlreadyBlocksForking || rootRulesUnknown)
|
|
// Sent to the backend: a locked restriction (enforced or not-yet-known) stays on regardless of the
|
|
// toggle's raw state, keeping the request consistent with what the locked toggle shows.
|
|
let effectiveLockProdDeploy = $derived(deployLocked || lockProdDeploy)
|
|
let effectiveLockProdForking = $derived(forkingLocked || lockProdForking)
|
|
|
|
let id = $state('')
|
|
let name = $state('')
|
|
let username = $state('')
|
|
|
|
let errorId = $state('')
|
|
let errorUser = $state('')
|
|
let aiKey = $state('')
|
|
let codeCompletionEnabled = $state(true)
|
|
let checking = $state(false)
|
|
|
|
let forkDatatableSection: ReturnType<typeof ForkDatatableSection> | undefined = $state(undefined)
|
|
let forkDucklakeSection: ReturnType<typeof ForkDucklakeSection> | undefined = $state(undefined)
|
|
|
|
let workspaceColor: string | undefined = $state(undefined)
|
|
let colorEnabled = $state(false)
|
|
let errorHandlerFallbackToInstanceAlerts = $state(false)
|
|
|
|
function generateRandomColor() {
|
|
const randomColor =
|
|
'#' +
|
|
Math.floor(Math.random() * 16777215)
|
|
.toString(16)
|
|
.padStart(6, '0')
|
|
workspaceColor = randomColor
|
|
}
|
|
|
|
async function validateName(id: string): Promise<void> {
|
|
checking = true
|
|
// For forks the actual workspace id is prefixed: checking the bare id
|
|
// would report the name as free even when `wm-fork-<id>` is taken
|
|
// (e.g. by an archived fork, which keeps its id reserved).
|
|
const effectiveId = isFork && !createAsDevWorkspace ? `${WM_FORK_PREFIX}${id}` : id
|
|
let exists =
|
|
id != '' && (await WorkspaceService.existsWorkspace({ requestBody: { id: effectiveId } }))
|
|
// The "delete existing fork to reclaim the id" affordance is only for prefixed forks.
|
|
forkIdTaken = isFork && !createAsDevWorkspace && exists
|
|
if (exists) {
|
|
errorId = forkIdTaken
|
|
? `A workspace with id '${effectiveId}' already exists. It may be an archived fork: archiving keeps the id reserved.`
|
|
: 'ID already exists'
|
|
} else {
|
|
// `effectiveId` carries the `wm-fork-` prefix into the length check, since
|
|
// that is what the backend stores.
|
|
errorId = (id != '' && validateWorkspaceId(id, effectiveId)) || ''
|
|
}
|
|
checking = false
|
|
}
|
|
|
|
const WM_FORK_PREFIX = 'wm-fork-'
|
|
|
|
// A dev workspace keeps its bare id; an ordinary fork is prefixed with `wm-fork-`.
|
|
const effectiveForkId = $derived(createAsDevWorkspace ? id : `${WM_FORK_PREFIX}${id}`)
|
|
|
|
let forkIdTaken = $state(false)
|
|
let deleteExistingForkOpen = $state(false)
|
|
let deletingExistingFork = $state(false)
|
|
|
|
async function deleteExistingFork(): Promise<void> {
|
|
if (deletingExistingFork) return
|
|
const prefixedId = `${WM_FORK_PREFIX}${id}`
|
|
deletingExistingFork = true
|
|
try {
|
|
await WorkspaceService.deleteWorkspace({ workspace: prefixedId })
|
|
// Drop local sessions bound to this id so they don't resurface (or
|
|
// auto-unarchive) against a new fork recreated under the same id.
|
|
// Fire-and-forget: neither a slow nor a failing IndexedDB op should
|
|
// block the delete/reuse flow (cleanup completes long before the UI
|
|
// could create a session in a recreated fork).
|
|
void deleteSessionsForWorkspace(prefixedId).catch((e) =>
|
|
console.error(`Session cleanup for reused fork id ${prefixedId} failed`, e)
|
|
)
|
|
sendUserToast(`Permanently deleted workspace ${prefixedId}`)
|
|
deleteExistingForkOpen = false
|
|
await validateName(id)
|
|
} catch (e: any) {
|
|
sendUserToast(`Failed to delete workspace ${prefixedId}: ${e?.body?.toString() ?? e}`, true)
|
|
} finally {
|
|
deletingExistingFork = false
|
|
}
|
|
}
|
|
|
|
let forkCreationLoading = $state(false)
|
|
let forkCreationError = $state('')
|
|
let errorMsgs: string[] = $state([])
|
|
let failedSyncJobs: string[] = $state([])
|
|
|
|
async function fetchFailedSyncJobs(jobs: string[]): Promise<CompletedJob[]> {
|
|
let ret: CompletedJob[] = []
|
|
for (const job of jobs) {
|
|
let j = await JobService.getCompletedJob({
|
|
id: job,
|
|
workspace: baseWorkspaceId!
|
|
})
|
|
ret.push(j)
|
|
}
|
|
return ret
|
|
}
|
|
|
|
function isPathVersionLessThan(path: string | undefined, version: number): boolean {
|
|
if (!path || !path.startsWith('hub/')) {
|
|
return false
|
|
}
|
|
|
|
const parts = path.split('/')
|
|
|
|
if (parts.length < 2) {
|
|
return false
|
|
}
|
|
|
|
const embeddedVersion = parseInt(parts[1], 10)
|
|
|
|
if (isNaN(embeddedVersion)) {
|
|
return false
|
|
}
|
|
|
|
return embeddedVersion < version
|
|
}
|
|
|
|
async function createOrForkWorkspace() {
|
|
if (isFork) {
|
|
await forkWorkspace(effectiveForkId)
|
|
} else {
|
|
await createWorkspace()
|
|
}
|
|
}
|
|
|
|
async function forkWorkspace(prefixed_id: string): Promise<void> {
|
|
if (baseWorkspaceId) {
|
|
forkCreationLoading = true
|
|
errorMsgs = []
|
|
failedSyncJobs = []
|
|
forkCreationError = ''
|
|
|
|
// Each data table copy is confirmed first; the fork request makes the copies
|
|
if (forkDatatableSection) {
|
|
const queue = forkDatatableSection.buildCloneQueue(prefixed_id)
|
|
if (queue.length > 0) {
|
|
forkDatatableSection.startCloning(queue)
|
|
return
|
|
}
|
|
}
|
|
|
|
await completeFork(prefixed_id)
|
|
} else {
|
|
sendUserToast('No workspace selected, cannot fork non-existent workspace', true)
|
|
}
|
|
}
|
|
|
|
async function completeFork(prefixed_id: string): Promise<void> {
|
|
// The fork request makes these copies, and drops them if the fork is not created
|
|
const forkedDatatables = forkDatatableSection
|
|
? forkDatatableSection.getConfirmedCloneJobs().map((job) => ({
|
|
name: job.name,
|
|
new_dbname: job._newDbName,
|
|
fork_behavior: job.behavior
|
|
}))
|
|
: []
|
|
|
|
let gitSyncJobIds: string[]
|
|
try {
|
|
gitSyncJobIds = await WorkspaceService.createWorkspaceForkGitBranch({
|
|
workspace: baseWorkspaceId!,
|
|
requestBody: {
|
|
id: prefixed_id,
|
|
name,
|
|
color: colorEnabled && workspaceColor ? workspaceColor : undefined,
|
|
// Sent in this first phase too, so a clone the fork would refuse is refused before
|
|
// any branch is created.
|
|
forked_datatables: forkedDatatables,
|
|
is_dev_workspace: createAsDevWorkspace,
|
|
dev_workspace_label: createAsDevWorkspace ? devWorkspaceLabel : undefined,
|
|
// Send the lock intent in this first phase too so the backend can reject a non-admin's
|
|
// locked-dev request before any branch is created (avoids dangling branches).
|
|
lock_prod_deploy: createAsDevWorkspace && effectiveLockProdDeploy,
|
|
lock_prod_forking: createAsDevWorkspace && effectiveLockProdForking,
|
|
copy_members: copyMembers
|
|
}
|
|
})
|
|
} catch (e) {
|
|
// The backend can reject here (fork cap, depth limit, premium, non-admin lock). Reset the
|
|
// loading state and surface the error rather than leaving the button spinning.
|
|
forkCreationError = `Failed to create fork '${prefixed_id}'`
|
|
errorMsgs.push(e?.body ?? e ?? 'Unknown error')
|
|
forkCreationLoading = false
|
|
sendUserToast(`Could not create fork '${prefixed_id}' ${e?.body ?? e}`, true)
|
|
return
|
|
}
|
|
|
|
try {
|
|
await Promise.all(
|
|
gitSyncJobIds.map((jobId) =>
|
|
jobManager.runWithProgress(() => Promise.resolve(jobId), {
|
|
workspace: baseWorkspaceId!,
|
|
timeout: 60000,
|
|
timeoutMessage: `Deploy fork job timed out after 60s`,
|
|
onProgress: (status) => {
|
|
if (status.status === 'failure') {
|
|
errorMsgs.push(status.error ?? 'Deploy fork job failed')
|
|
failedSyncJobs.push(jobId)
|
|
}
|
|
}
|
|
})
|
|
)
|
|
)
|
|
} catch (error) {
|
|
forkCreationLoading = false
|
|
sendUserToast(
|
|
`Could not fork workspace ${baseWorkspaceId} because branch creation failed: ${errorMsgs} - ${error}`,
|
|
true
|
|
)
|
|
return
|
|
}
|
|
if (errorMsgs.length != 0) {
|
|
forkCreationError = 'Failed to create a branch for this fork on the git sync repo(s)'
|
|
forkCreationLoading = false
|
|
sendUserToast(
|
|
`Could not fork workspace ${baseWorkspaceId} because branch creation failed: ${errorMsgs}`,
|
|
true
|
|
)
|
|
return
|
|
}
|
|
|
|
try {
|
|
await createWorkspaceForkAndWait(baseWorkspaceId!, {
|
|
id: prefixed_id,
|
|
name,
|
|
color: colorEnabled && workspaceColor ? workspaceColor : undefined,
|
|
forked_datatables: forkedDatatables,
|
|
shared_ducklakes: forkDucklakeSection?.getSharedDucklakes() ?? [],
|
|
is_dev_workspace: createAsDevWorkspace,
|
|
dev_workspace_label: createAsDevWorkspace ? devWorkspaceLabel : undefined,
|
|
lock_prod_deploy: createAsDevWorkspace && effectiveLockProdDeploy,
|
|
lock_prod_forking: createAsDevWorkspace && effectiveLockProdForking,
|
|
copy_members: copyMembers
|
|
})
|
|
} catch (e) {
|
|
const msg = e?.body ?? e?.message ?? e ?? 'Unknown error'
|
|
forkCreationError = `Failed to create fork '${prefixed_id}'`
|
|
errorMsgs.push(msg)
|
|
forkCreationLoading = false
|
|
sendUserToast(`Could not create fork '${prefixed_id}' ${msg}`, true)
|
|
return
|
|
}
|
|
|
|
forkCreationLoading = false
|
|
sendUserToast(
|
|
createAsDevWorkspace
|
|
? `Created ${devWorkspaceLabel} workspace ${effectiveForkId} for ${baseWorkspaceId}`
|
|
: `Successfully forked workspace ${baseWorkspaceId} as: wm-fork-${id}`
|
|
)
|
|
|
|
await enterNewWorkspace(prefixed_id)
|
|
|
|
onFinish?.()
|
|
}
|
|
|
|
async function createWorkspace(): Promise<void> {
|
|
await WorkspaceService.createWorkspace({
|
|
requestBody: {
|
|
id,
|
|
name,
|
|
color: colorEnabled && workspaceColor ? workspaceColor : undefined,
|
|
username: automateUsernameCreation ? undefined : username,
|
|
error_handler_fallback_to_instance_alerts: errorHandlerFallbackToInstanceAlerts
|
|
}
|
|
})
|
|
if (auto_invite) {
|
|
await WorkspaceService.editAutoInvite({
|
|
workspace: id,
|
|
requestBody: { operator: operatorOnly, invite_all: !isCloudHosted(), auto_add: autoAdd }
|
|
})
|
|
}
|
|
if (aiKey != '') {
|
|
let actualUsername = username
|
|
if (automateUsernameCreation) {
|
|
const user = await UserService.whoami({
|
|
workspace: id
|
|
})
|
|
actualUsername = user.username
|
|
}
|
|
let path = `u/${actualUsername}/${selected}_windmill_codegen`
|
|
await VariableService.createVariable({
|
|
workspace: id,
|
|
requestBody: {
|
|
path,
|
|
value: aiKey,
|
|
is_secret: true,
|
|
description: 'Ai token'
|
|
}
|
|
})
|
|
await ResourceService.createResource({
|
|
workspace: id,
|
|
requestBody: {
|
|
path,
|
|
value: {
|
|
api_key: '$var:' + path
|
|
},
|
|
resource_type: selected
|
|
}
|
|
})
|
|
await WorkspaceService.editCopilotConfig({
|
|
workspace: id,
|
|
requestBody: aiKey
|
|
? {
|
|
providers: {
|
|
[selected]: {
|
|
resource_path: path,
|
|
models: [AI_PROVIDERS[selected].defaultModels[0]]
|
|
}
|
|
},
|
|
default_model: {
|
|
model: AI_PROVIDERS[selected].defaultModels[0],
|
|
provider: selected
|
|
},
|
|
code_completion_model: codeCompletionEnabled
|
|
? { model: AI_PROVIDERS[selected].defaultModels[0], provider: selected }
|
|
: undefined
|
|
}
|
|
: {}
|
|
})
|
|
}
|
|
|
|
sendUserToast(`Created workspace id: ${id}`)
|
|
|
|
await enterNewWorkspace(id)
|
|
onFinish?.()
|
|
}
|
|
|
|
function handleKeyUp(event: KeyboardEvent) {
|
|
const key = event.key
|
|
if (key === 'Enter') {
|
|
event.preventDefault()
|
|
createWorkspace()
|
|
}
|
|
}
|
|
|
|
async function loadWorkspaces() {
|
|
if (!$usersWorkspaceStore) {
|
|
try {
|
|
await refreshWorkspaceList()
|
|
} catch {}
|
|
}
|
|
if (!$usersWorkspaceStore) {
|
|
const url = page.url
|
|
console.log('logout 2')
|
|
await logoutWithRedirect(url.href.replace(url.origin, ''))
|
|
}
|
|
}
|
|
|
|
let automateUsernameCreation = $state(true)
|
|
async function getAutomateUsernameCreationSetting() {
|
|
const policy = await loadUsernamePolicy()
|
|
automateUsernameCreation = policy.automate
|
|
if (policy.suggested) {
|
|
username = policy.suggested
|
|
}
|
|
}
|
|
getAutomateUsernameCreationSetting()
|
|
|
|
onMount(() => {
|
|
loadWorkspaces()
|
|
|
|
// Settle on a value rather than leaving the section stuck in its in-flight
|
|
// state, and settle on `false`: `createWorkspace` commits the workspace
|
|
// before `editAutoInvite`, so offering the toggle to a domain the backend
|
|
// then rejects leaves a created workspace behind a failed submit.
|
|
WorkspaceService.isDomainAllowed()
|
|
.then((x) => {
|
|
isDomainAllowed = x
|
|
})
|
|
.catch(() => {
|
|
isDomainAllowed = false
|
|
})
|
|
})
|
|
|
|
let isDomainAllowed: undefined | boolean = $state(undefined)
|
|
|
|
let auto_invite = $state(false)
|
|
let operatorOnly = $state(false)
|
|
let autoAdd = $state(true)
|
|
let selected: Exclude<AIProvider, 'customai'> = $state('openai')
|
|
run(() => {
|
|
if (isFork) {
|
|
// Forks have no separate display name — the unique id is the name.
|
|
name = id
|
|
} else {
|
|
id = name.toLowerCase().replace(/\s/gi, '-')
|
|
}
|
|
})
|
|
// When creating a dev workspace, prefill the fork name with `<root>-<badge>` (the effect above
|
|
// slugifies it into the id). Only fill an empty field or one still holding a prior suggestion, so a
|
|
// user-typed name is never overwritten; changing the label updates the suffix, and turning the dev
|
|
// toggle back off clears the suggestion.
|
|
let lastAutoDevName = $state<string | undefined>(undefined)
|
|
$effect(() => {
|
|
const target =
|
|
createAsDevWorkspace && $workspaceStore
|
|
? `${$workspaceStore}-${devBadgeText(devWorkspaceLabel)}`
|
|
: ''
|
|
if (name === '' || name === lastAutoDevName) {
|
|
name = target
|
|
lastAutoDevName = target === '' ? undefined : target
|
|
}
|
|
})
|
|
run(() => {
|
|
validateName(id)
|
|
})
|
|
run(() => {
|
|
errorUser = validateUsername(username)
|
|
})
|
|
run(() => {
|
|
colorEnabled && !workspaceColor && generateRandomColor()
|
|
})
|
|
let domain = $derived($usersWorkspaceStore?.email.split('@')[1])
|
|
</script>
|
|
|
|
<div class="flex flex-col flex-1 min-h-0">
|
|
<!-- Standalone page: fixed 32rem scroll area (absolute inset). In the modal the
|
|
form flows naturally so the adaptive modal hugs the content, scrolling only
|
|
when capped by the viewport. -->
|
|
<div class={inModal ? 'flex-1 min-h-0 overflow-y-auto' : 'flex-1 relative min-h-[32rem]'}>
|
|
<div
|
|
class={inModal
|
|
? 'flex flex-col gap-8'
|
|
: 'flex flex-col gap-8 absolute inset-0 overflow-y-auto'}
|
|
>
|
|
{#if errorMsgs.length != 0}
|
|
<Alert class="p-2" title={forkCreationError} type="error">
|
|
<ul class="pl-2 pr-4 break-words pb-5">
|
|
{#each errorMsgs as errorMsg}
|
|
<li><pre class="whitespace-pre-wrap">- {errorMsg}</pre></li>
|
|
{/each}
|
|
</ul>
|
|
{#if failedSyncJobs.length != 0}
|
|
More details on the jobs that failed:
|
|
{#await fetchFailedSyncJobs(failedSyncJobs)}
|
|
<LoaderCircle class="animate-spin" />
|
|
{:then failedJobs}
|
|
<ul class="pl-2 pr-4 break-words">
|
|
{#each failedJobs as job}
|
|
<li>
|
|
-
|
|
<a
|
|
target="_blank"
|
|
class="underline"
|
|
href={`/run/${job.id}?workspace=${baseWorkspaceId}`}
|
|
>
|
|
{job.id}
|
|
</a>
|
|
</li>
|
|
<!-- This 28073 is the version where git sync on fork was introduced -->
|
|
{#if isPathVersionLessThan(job.script_path, 28073)}
|
|
<div class="font-bold">
|
|
This job was not running the latest version of the git sync script available
|
|
on the hub. You might be able to solve this issue by going to `Workspace
|
|
Settings` -> `Git Sync` and updating the script.
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
</ul>
|
|
{:catch error}
|
|
Tried to fetch jobs to get more information, but failed: {error}. Here are the failed
|
|
job ids:
|
|
<ul class="pl-2 pr-4 break-words">
|
|
{#each failedSyncJobs as jobId}
|
|
<li>
|
|
-
|
|
<a
|
|
target="_blank"
|
|
class="underline"
|
|
href={`/run/${jobId}?workspace=${baseWorkspaceId}`}
|
|
>
|
|
{jobId}
|
|
</a>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/await}
|
|
{/if}
|
|
</Alert>
|
|
{/if}
|
|
{#if !isFork}
|
|
<label class="flex flex-col gap-1">
|
|
<span class="text-xs font-semibold text-emphasis">Workspace name</span>
|
|
<span class="text-xs text-secondary">Displayable name</span>
|
|
<!-- svelte-ignore a11y_autofocus -->
|
|
<TextInput inputProps={{ autofocus: true }} bind:value={name} />
|
|
</label>
|
|
{/if}
|
|
<label class="flex flex-col gap-1">
|
|
{#if isFork}
|
|
<span class="text-xs font-semibold text-emphasis">Fork ID</span>
|
|
<span class="text-xs text-secondary"
|
|
>Unique name of your fork (this will also set the branch name)</span
|
|
>
|
|
{:else}
|
|
<span class="text-xs font-semibold text-emphasis">Workspace ID</span>
|
|
<span class="text-xs text-secondary">Slug to uniquely identify your workspace</span>
|
|
{/if}
|
|
|
|
{#if isFork && !createAsDevWorkspace}
|
|
<PrefixedInput
|
|
prefix={WM_FORK_PREFIX}
|
|
bind:value={id}
|
|
placeholder="my-fork"
|
|
autofocus
|
|
error={errorId != ''}
|
|
/>
|
|
{:else}
|
|
<TextInput bind:value={id} error={errorId} />
|
|
{/if}
|
|
{#if errorId}
|
|
<span class="text-red-500 text-2xs font-normal">{errorId}</span>
|
|
{#if forkIdTaken}
|
|
<div class="flex flex-row items-center gap-2 pt-1">
|
|
<Button
|
|
destructive
|
|
size="xs"
|
|
startIcon={{ icon: Trash2 }}
|
|
disabled={deletingExistingFork}
|
|
on:click={() => (deleteExistingForkOpen = true)}
|
|
>
|
|
Permanently delete existing fork
|
|
</Button>
|
|
<span class="text-2xs text-secondary">
|
|
Frees up the id so you can reuse it (fork owner or superadmin only)
|
|
</span>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</label>
|
|
<Label label={isFork ? 'Fork color' : 'Workspace color'}>
|
|
{#snippet header()}
|
|
<span class="text-2xs text-secondary">(optional)</span>
|
|
{/snippet}
|
|
<span class="text-xs text-secondary">
|
|
Color to identify the current workspace in the list of workspaces
|
|
</span>
|
|
<div class="flex items-center gap-4">
|
|
<Toggle bind:checked={colorEnabled} options={{ right: 'Enable' }} />
|
|
{#if colorEnabled}
|
|
<div class="flex items-center gap-1 grow">
|
|
<input
|
|
class="grow min-w-10"
|
|
type="color"
|
|
bind:value={workspaceColor}
|
|
disabled={!colorEnabled}
|
|
/>
|
|
|
|
<TextInput
|
|
class="w-24"
|
|
bind:value={workspaceColor}
|
|
inputProps={{ disabled: !colorEnabled }}
|
|
/>
|
|
<Button
|
|
on:click={generateRandomColor}
|
|
size="xs"
|
|
variant="default"
|
|
disabled={!colorEnabled}>Random</Button
|
|
>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{#if isFork && colorEnabled}
|
|
<div class="flex flex-col gap-1 pt-2">
|
|
<div class="flex flex-row items-center gap-1">
|
|
<span class="text-2xs text-secondary">Fork picker preview</span>
|
|
<!-- Toggles the real app theme: a preview-local .dark scope can't
|
|
re-theme the modal (theme vars are bound to html.dark), and the
|
|
actual theme is what the chip must be judged against anyway. -->
|
|
<DarkModeToggle forcedDarkMode={false} />
|
|
</div>
|
|
<div class="w-64">
|
|
<WorkspaceScopeTrigger
|
|
workspaceId={baseWorkspaceId}
|
|
pendingFork={{
|
|
id: effectiveForkId,
|
|
name: id || 'my-fork',
|
|
parent_workspace_id: baseWorkspaceId ?? ''
|
|
}}
|
|
color={workspaceColor}
|
|
showChevron={false}
|
|
wrap
|
|
class="w-full pointer-events-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</Label>
|
|
{#if !isFork && !isCloudHosted() && $enterpriseLicense}
|
|
<Label label="Error reporting">
|
|
{#snippet header()}
|
|
<span class="text-2xs text-secondary">(optional)</span>
|
|
{/snippet}
|
|
<span class="text-xs text-secondary">
|
|
Until this workspace has its own error handler, report failed jobs to the Slack, Teams
|
|
and email channels a superadmin configured in the instance critical alert settings.
|
|
</span>
|
|
<div class="pt-1">
|
|
<Toggle
|
|
bind:checked={errorHandlerFallbackToInstanceAlerts}
|
|
options={{ right: 'Use the instance critical alert channels' }}
|
|
/>
|
|
</div>
|
|
</Label>
|
|
{/if}
|
|
{#if isFork && canDesignateDevWorkspace}
|
|
<Label label="Persistent dev workspace">
|
|
<span class="text-xs text-secondary">
|
|
Create a standing dev workspace (no <code>wm-fork-</code> prefix) paired with this workspace,
|
|
instead of a throwaway fork.
|
|
</span>
|
|
<div class="flex flex-col gap-2 pt-1">
|
|
<Toggle bind:checked={createAsDevWorkspace} options={{ right: 'Dev workspace' }} />
|
|
{#if createAsDevWorkspace}
|
|
<DevWorkspaceLabelPicker
|
|
bind:value={devWorkspaceLabel}
|
|
takenLabels={chainTakenLabels}
|
|
/>
|
|
<div class="flex flex-col gap-2 rounded-md border bg-surface-secondary p-3">
|
|
<div class="flex flex-col gap-0.5">
|
|
<span class="text-xs font-semibold text-emphasis"
|
|
>Protect {currentWorkspaceName}</span
|
|
>
|
|
<span class="text-2xs text-secondary">
|
|
Adds protection rules to the base workspace so changes are made in the new dev
|
|
workspace and promoted there.
|
|
</span>
|
|
</div>
|
|
{#if deployLocked}
|
|
<div class="flex flex-col gap-0.5">
|
|
<Toggle
|
|
checked
|
|
disabled
|
|
options={{ right: 'Block direct edits (deploy via the dev workspace)' }}
|
|
/>
|
|
{#if rootAlreadyBlocksDeploy}
|
|
<span class="text-2xs text-secondary ml-11"
|
|
>Already enforced by an existing protection rule</span
|
|
>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<Toggle
|
|
bind:checked={lockProdDeploy}
|
|
options={{ right: 'Block direct edits (deploy via the dev workspace)' }}
|
|
/>
|
|
{/if}
|
|
{#if forkingLocked}
|
|
<div class="flex flex-col gap-0.5">
|
|
<Toggle checked disabled options={{ right: 'Prevent forking' }} />
|
|
{#if rootAlreadyBlocksForking}
|
|
<span class="text-2xs text-secondary ml-11"
|
|
>Already enforced by an existing protection rule</span
|
|
>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<Toggle bind:checked={lockProdForking} options={{ right: 'Prevent forking' }} />
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</Label>
|
|
{/if}
|
|
{#if isFork && createAsDevWorkspace}
|
|
<Label label="Members">
|
|
<span class="text-xs text-secondary">
|
|
Copy this workspace's members (and their group memberships) into the dev workspace so
|
|
the team can work in it.
|
|
</span>
|
|
<div class="pt-1">
|
|
<Toggle bind:checked={copyMembers} options={{ right: 'Copy members' }} />
|
|
</div>
|
|
</Label>
|
|
{/if}
|
|
{#if isFork}
|
|
<label class="flex flex-col gap-1">
|
|
<div class="flex flex-row gap-2 items-baseline">
|
|
<span class="text-xs font-semibold text-emphasis">Base workspace</span>
|
|
<span class="text-2xs text-secondary">(optional)</span>
|
|
</div>
|
|
<span class="text-xs text-secondary">
|
|
{#if createAsDevWorkspace}
|
|
A dev workspace is paired with the workspace it is based on, which here is {currentWorkspaceName}.
|
|
{:else}
|
|
Workspace to fork from: the new branch is based on the selected workspace's branch.
|
|
Pick an existing fork to create a fork of a fork.
|
|
{/if}
|
|
</span>
|
|
<Select
|
|
items={baseItems}
|
|
bind:value={baseWorkspaceId}
|
|
placeholder="Select a base workspace"
|
|
disabled={createAsDevWorkspace}
|
|
/>
|
|
</label>
|
|
{#if baseAddsAdminsAndDevelopers && !(createAsDevWorkspace && copyMembers)}
|
|
<Alert type="info" size="xs" title="Admins and developers join this fork">
|
|
{baseWorkspaceId} adds its admins and developers to every new fork, with the role they have
|
|
there.
|
|
</Alert>
|
|
{/if}
|
|
{/if}
|
|
{#if isFork}
|
|
<ForkDatatableSection
|
|
bind:this={forkDatatableSection}
|
|
sourceWorkspace={baseWorkspaceId}
|
|
onAllDone={() => {
|
|
completeFork(effectiveForkId)
|
|
}}
|
|
onCanceled={() => {
|
|
forkCreationLoading = false
|
|
}}
|
|
/>
|
|
<ForkDucklakeSection bind:this={forkDucklakeSection} sourceWorkspace={baseWorkspaceId} />
|
|
{/if}
|
|
{#if !automateUsernameCreation}
|
|
<Label label="Your username in that workspace">
|
|
<TextInput
|
|
bind:value={username}
|
|
inputProps={{ onkeyup: handleKeyUp }}
|
|
error={errorUser}
|
|
/>
|
|
{#if errorUser}
|
|
<span class="text-red-500 text-2xs">{errorUser}</span>
|
|
{/if}
|
|
</Label>
|
|
{/if}
|
|
{#if !isFork}
|
|
<div class="block">
|
|
<div class="flex flex-col gap-1">
|
|
<label for="ai-key" class="flex flex-row gap-2">
|
|
<span class="text-xs font-semibold text-emphasis">
|
|
AI key for Windmill AI
|
|
<Tooltip>
|
|
Find out how it can help you <a
|
|
href="https://www.windmill.dev/docs/core_concepts/ai_generation"
|
|
target="_blank"
|
|
rel="noopener noreferrer">in the docs</a
|
|
>
|
|
</Tooltip>
|
|
</span>
|
|
<span class="text-2xs text-secondary">(optional but recommended)</span>
|
|
</label>
|
|
|
|
<ToggleButtonGroup bind:selected>
|
|
{#snippet children({ item })}
|
|
<ToggleButton value="openai" label="OpenAI" {item} />
|
|
<ToggleButton value="anthropic" label="Anthropic" {item} />
|
|
<ToggleButton value="mistral" label="Mistral" {item} />
|
|
<ToggleButton value="deepseek" label="DeepSeek" {item} />
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
<div class="flex flex-row gap-1">
|
|
<input
|
|
id="ai-key"
|
|
type="password"
|
|
autocomplete="new-password"
|
|
bind:value={aiKey}
|
|
onkeyup={handleKeyUp}
|
|
/>
|
|
<TestAIKey
|
|
apiKey={aiKey}
|
|
disabled={!aiKey}
|
|
aiProvider={selected}
|
|
model={AI_PROVIDERS[selected].defaultModels[0]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{#if aiKey}
|
|
<div class="flex flex-col gap-2 mt-2">
|
|
<Toggle
|
|
disabled={!aiKey}
|
|
bind:checked={codeCompletionEnabled}
|
|
options={{ right: 'Enable code completion' }}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<!-- Strict `=== true`: undefined means the domain check is still in flight,
|
|
and rendering then is a section that flashes in and back out. -->
|
|
{#if !isCloudHosted() || isDomainAllowed === true}
|
|
<div class="flex flex-col gap-1">
|
|
<label for="auto-invite" class="text-xs font-semibold text-emphasis"
|
|
>{isCloudHosted()
|
|
? `Auto-${autoAdd ? 'add' : 'invite'} anyone from ${domain}`
|
|
: `Auto-${autoAdd ? 'add' : 'invite'} anyone joining the instance`}</label
|
|
>
|
|
<Toggle id="auto-invite" bind:checked={auto_invite} />
|
|
|
|
{#if auto_invite}
|
|
<div class="bg-surface-tertiary p-4 rounded-md flex flex-col gap-8">
|
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
|
{#if isCloudHosted()}
|
|
<label class="flex flex-col gap-1">
|
|
<span class="text-xs font-semibold text-emphasis">Mode</span>
|
|
<span class="text-xs text-secondary font-normal"
|
|
>Whether to invite or add users directly to the workspace.</span
|
|
>
|
|
<ToggleButtonGroup
|
|
selected={autoAdd ? 'add' : 'invite'}
|
|
on:selected={async (e) => {
|
|
autoAdd = e.detail === 'add'
|
|
}}
|
|
>
|
|
{#snippet children({ item })}
|
|
<ToggleButton value="invite" label="Auto-invite" {item} />
|
|
<ToggleButton value="add" label="Auto-add" {item} />
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
</label>
|
|
{/if}
|
|
|
|
<label class="font-semibold flex flex-col gap-1">
|
|
<span class="text-xs font-semibold text-emphasis">Role</span>
|
|
<span class="text-xs text-secondary font-normal"
|
|
>Role of the auto-invited users</span
|
|
>
|
|
<ToggleButtonGroup
|
|
selected={operatorOnly ? 'operator' : 'developer'}
|
|
on:selected={(e) => {
|
|
operatorOnly = e.detail == 'operator'
|
|
}}
|
|
>
|
|
{#snippet children({ item })}
|
|
<ToggleButton value="operator" label="Operator" {item} />
|
|
<ToggleButton value="developer" label="Developer" {item} />
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
</label>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-wrap flex-row {inModal ? 'justify-end' : 'justify-between'} gap-4 pt-4">
|
|
{#if !inModal}
|
|
<Button
|
|
disabled={forkCreationLoading}
|
|
variant="default"
|
|
size="sm"
|
|
href="{base}/user/workspaces">← Back to workspaces</Button
|
|
>
|
|
{/if}
|
|
{#if !forkCreationLoading}
|
|
<Button
|
|
variant="accent"
|
|
disabled={checking ||
|
|
errorId != '' ||
|
|
!name ||
|
|
(!automateUsernameCreation && (errorUser != '' || !username)) ||
|
|
!id}
|
|
on:click={createOrForkWorkspace}
|
|
>
|
|
{#if isFork}
|
|
Fork workspace
|
|
{:else}
|
|
Create workspace
|
|
{/if}
|
|
</Button>
|
|
{:else}
|
|
<Button variant="accent" disabled={true}>
|
|
<LoaderCircle class="animate-spin" /> Creating branch
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<ConfirmationModal
|
|
open={deleteExistingForkOpen}
|
|
title="Permanently delete existing fork"
|
|
confirmationText="Delete permanently"
|
|
loading={deletingExistingFork}
|
|
on:canceled={() => {
|
|
deleteExistingForkOpen = false
|
|
}}
|
|
on:confirmed={() => {
|
|
deleteExistingFork()
|
|
}}
|
|
>
|
|
<div class="flex flex-col w-full space-y-4">
|
|
<span>
|
|
This will permanently delete the workspace '{WM_FORK_PREFIX}{id}' and all of its content
|
|
(scripts, flows, apps, variables, resources, runs). This cannot be undone. Unlike archiving,
|
|
this frees up the workspace id for a new fork.
|
|
</span>
|
|
</div>
|
|
</ConfirmationModal>
|