Files
windmill/frontend/src/lib/components/RemoteDeployTargetSetting.svelte
T
Ruben FiszelandClaude Opus 5 4d12ea4614 feat: deploy from the UI to a workspace on another instance (#11245)
* feat: deploy from the UI to a workspace on another instance

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the remote deploy proxy from being spent by a link

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: key remote deploy proxy URLs instead of a global client header

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the remote deploy proxy key out of logs and restricted hands

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: serialize remote deploy connect with account and key changes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: key remote deploy tokens to the account and connect by signing in

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: bind remote deploy connect to its target and order its locks

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: serialize remote deploy connect with target changes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: list every lock remote deploy connect takes in auth-surface

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: never wait on the membership lock in remote deploy connect

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep a superseded target response out of the settings form

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: void stale remote deploy tokens on read instead of locking in connect

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: void remote deploy tokens older than the last target change

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: order remote deploy connections by when their connect started

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: bind stored remote deploy tokens to the membership and target they were connected under

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: answer remote deploy connect without settings as no target

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:40:53 +02:00

112 lines
3.6 KiB
Svelte

<script lang="ts">
import { WorkspaceService } from '$lib/gen'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { resource } from 'runed'
import { Button } from './common'
import Alert from './common/alert/Alert.svelte'
import SettingCard from './instanceSettings/SettingCard.svelte'
import TextInput from './text_input/TextInput.svelte'
let baseUrl = $state('')
let remoteWorkspaceId = $state('')
let saving = $state(false)
const targetResource = resource(
() => $workspaceStore,
async (workspace, previous, { signal }) => {
if (workspace !== previous) {
baseUrl = ''
remoteWorkspaceId = ''
}
const status = workspace
? await WorkspaceService.getRemoteDeployTarget({ workspace })
: undefined
// runed keeps a superseded fetch's result: filling the form with it would let a save
// point the workspace switched to at the previous one's instance.
if (signal.aborted) throw new DOMException('superseded', 'AbortError')
baseUrl = status?.target?.base_url ?? ''
remoteWorkspaceId = status?.target?.workspace_id ?? ''
return { workspace, target: status?.target }
}
)
let loaded = $derived(
targetResource.current?.workspace === $workspaceStore ? targetResource.current : undefined
)
let saved = $derived(loaded?.target)
let hasChanges = $derived(
baseUrl !== (saved?.base_url ?? '') || remoteWorkspaceId !== (saved?.workspace_id ?? '')
)
async function save(target: { base_url: string; workspace_id: string } | undefined) {
const workspace = loaded?.workspace
if (!workspace) return
saving = true
try {
const message = await WorkspaceService.setRemoteDeployTarget({
workspace,
requestBody: { target }
})
sendUserToast(message)
await targetResource.refetch()
} catch (e: any) {
sendUserToast(e?.body ?? e?.message ?? String(e), true)
} finally {
saving = false
}
}
</script>
<SettingCard
label="Workspace on another instance"
description="Deploy the same items to a workspace on a different Windmill instance. Each person deploying connects with their own token for that instance, from the deploy drawer."
class="mt-6"
>
{#if !$enterpriseLicense}
<Alert type="warning" title="Enterprise license required">
Deploying to another instance from the web UI is only available with an enterprise license
</Alert>
{:else}
<div class="flex flex-col gap-3 mt-2 max-w-md">
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Instance URL</span>
<TextInput
bind:value={baseUrl}
inputProps={{ placeholder: 'https://windmill.example.com' }}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Workspace on that instance</span>
<TextInput bind:value={remoteWorkspaceId} inputProps={{ placeholder: 'prod' }} />
</label>
<div class="flex flex-row gap-2">
<Button
variant="accent"
unifiedSize="sm"
disabled={!loaded || !hasChanges || !baseUrl || !remoteWorkspaceId || saving}
onclick={() => save({ base_url: baseUrl, workspace_id: remoteWorkspaceId })}
>
Save
</Button>
{#if saved}
<Button
variant="default"
unifiedSize="sm"
destructive
disabled={saving}
onclick={() => save(undefined)}
>
Remove target
</Button>
{/if}
</div>
{#if saved}
<p class="text-xs text-secondary">
Changing the instance URL or workspace drops every token stored for the current target, so
everyone connects again.
</p>
{/if}
</div>
{/if}
</SettingCard>