mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-18 00:03:11 +00:00
* 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>
182 lines
5.2 KiB
Svelte
182 lines
5.2 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/state'
|
|
import { untrack } from 'svelte'
|
|
import { WorkspaceService, type RemoteDeployConnection, type RemoteDeployTarget } from '$lib/gen'
|
|
import {
|
|
CHANNEL,
|
|
remoteDeployAuthorizeUrl,
|
|
remoteHost,
|
|
type RemoteDeployEvent
|
|
} from '$lib/remoteDeploy'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { ExternalLink, LogIn, Unplug } from 'lucide-svelte'
|
|
import { Button } from './common'
|
|
import Password from './Password.svelte'
|
|
|
|
let {
|
|
workspace,
|
|
target,
|
|
connection,
|
|
returnTo,
|
|
onChange
|
|
}: {
|
|
workspace: string
|
|
target: RemoteDeployTarget
|
|
connection: RemoteDeployConnection | undefined
|
|
/** Where to land after signing in when the popup is blocked and this tab navigates away:
|
|
* a page that shows the connection, since a drawer does not survive the round trip. */
|
|
returnTo?: string
|
|
onChange: () => void
|
|
} = $props()
|
|
|
|
let host = $derived(remoteHost(target.base_url))
|
|
let token: string | undefined = $state(undefined)
|
|
let connecting = $state(false)
|
|
let waitingForRemote = $state(false)
|
|
let showPaste = $state(false)
|
|
let error: string | undefined = $state(undefined)
|
|
let popupWatch: ReturnType<typeof setInterval> | undefined = undefined
|
|
|
|
function signIn() {
|
|
error = undefined
|
|
const url = remoteDeployAuthorizeUrl(
|
|
target,
|
|
workspace,
|
|
returnTo ?? page.url.pathname + page.url.search
|
|
)
|
|
// Opened blank and cut loose before it navigates: the remote page gets no handle on this
|
|
// window, and a blocked popup is still detected.
|
|
const popup = window.open('', 'windmill-remote-deploy', 'popup,width=640,height=760')
|
|
if (!popup) {
|
|
window.location.href = url
|
|
return
|
|
}
|
|
popup.opener = null
|
|
popup.location.href = url
|
|
waitingForRemote = true
|
|
clearInterval(popupWatch)
|
|
popupWatch = setInterval(() => {
|
|
if (popup.closed) {
|
|
clearInterval(popupWatch)
|
|
waitingForRemote = false
|
|
}
|
|
}, 500)
|
|
}
|
|
|
|
$effect(() => () => clearInterval(popupWatch))
|
|
|
|
// The drawer stays mounted across a workspace switch: a token pasted for one target must not
|
|
// be submitted to the next one.
|
|
$effect(() => {
|
|
;[workspace, target.base_url, target.workspace_id]
|
|
untrack(() => {
|
|
token = undefined
|
|
error = undefined
|
|
})
|
|
})
|
|
|
|
// The callback page runs in the popup, or in this tab when the popup was blocked.
|
|
$effect(() => {
|
|
const channel = new BroadcastChannel(CHANNEL)
|
|
channel.onmessage = (event: MessageEvent<RemoteDeployEvent>) => {
|
|
if (event.data?.workspace !== workspace) return
|
|
waitingForRemote = false
|
|
if (event.data.type === 'connected') {
|
|
onChange()
|
|
} else {
|
|
error = event.data.error
|
|
}
|
|
}
|
|
return () => channel.close()
|
|
})
|
|
|
|
async function connect() {
|
|
if (!token) return
|
|
connecting = true
|
|
error = undefined
|
|
try {
|
|
await WorkspaceService.connectRemoteDeploy({ workspace, requestBody: { token, target } })
|
|
token = undefined
|
|
onChange()
|
|
} catch (e: any) {
|
|
error = e?.body ?? e?.message ?? String(e)
|
|
} finally {
|
|
connecting = false
|
|
}
|
|
}
|
|
|
|
async function disconnect() {
|
|
await WorkspaceService.disconnectRemoteDeploy({ workspace })
|
|
sendUserToast(`Disconnected from ${target.base_url}`)
|
|
onChange()
|
|
}
|
|
</script>
|
|
|
|
{#if connection}
|
|
<div class="flex flex-row items-center gap-2 text-xs text-secondary">
|
|
<span>
|
|
Deploying as <span class="font-semibold text-primary">{connection.remote_email}</span> on
|
|
{target.base_url}
|
|
</span>
|
|
<Button variant="subtle" unifiedSize="xs" startIcon={{ icon: Unplug }} onclick={disconnect}>
|
|
Disconnect
|
|
</Button>
|
|
</div>
|
|
{:else}
|
|
<div class="flex flex-col gap-3 max-w-xl">
|
|
<p class="text-xs text-secondary">
|
|
Deploys to {target.workspace_id} on {target.base_url} run with your own account on that instance,
|
|
so its permissions, protection rules and audit logs apply. Sign in there to connect; the token
|
|
it issues is stored encrypted and only ever sent to that instance.
|
|
</p>
|
|
<div class="flex flex-row items-center gap-3">
|
|
<Button variant="accent" unifiedSize="sm" startIcon={{ icon: LogIn }} onclick={signIn}>
|
|
Sign in to {host}
|
|
</Button>
|
|
{#if waitingForRemote}
|
|
<span class="text-xs text-secondary">Waiting for {host}…</span>
|
|
{/if}
|
|
<Button variant="subtle" unifiedSize="sm" onclick={() => (showPaste = !showPaste)}>
|
|
{showPaste ? 'Hide token field' : 'Paste a token instead'}
|
|
</Button>
|
|
</div>
|
|
{#if showPaste}
|
|
<div class="flex flex-col gap-2">
|
|
<a
|
|
class="text-xs flex flex-row items-center gap-1 w-fit"
|
|
href="{target.base_url}/#user-settings"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
Create a token on {host}
|
|
<ExternalLink size={12} />
|
|
</a>
|
|
<div class="flex flex-row gap-2 items-start">
|
|
<div class="grow">
|
|
<Password
|
|
bind:password={token}
|
|
small
|
|
allowMultiline={false}
|
|
placeholder="Token for {target.base_url}"
|
|
error={error != undefined}
|
|
onKeyDown={(e) => e.key == 'Enter' && connect()}
|
|
/>
|
|
</div>
|
|
<Button
|
|
variant="default"
|
|
unifiedSize="sm"
|
|
disabled={!token || connecting}
|
|
loading={connecting}
|
|
onclick={connect}
|
|
>
|
|
Connect
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{#if error}
|
|
<p class="text-xs text-red-500 dark:text-red-400 break-words">{error}</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|