Files
windmill/frontend/src/lib/components/git_sync/PullWorkspaceModal.svelte
T
2025-11-04 20:01:14 +00:00

644 lines
20 KiB
Svelte

<script lang="ts">
import Modal from '$lib/components/common/modal/Modal.svelte'
import { Button, Alert, Badge } from '$lib/components/common'
import {
Loader2,
CheckCircle2,
XCircle,
Terminal,
ChevronDown,
ChevronUp,
Save,
Edit3
} from 'lucide-svelte'
import GitDiffPreview from '../GitDiffPreview.svelte'
import { JobService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import hubPaths from '$lib/hubPaths.json'
import { jobManager } from '$lib/services/JobManager'
import type { SyncResponse, SettingsResponse, SettingsObject } from '$lib/git-sync'
interface Props {
open: boolean
gitRepoResourcePath: string
uiState: SettingsObject
repoIndex?: number
currentGitSyncSettings?: any
onFilterUpdate?: (filters: SettingsObject) => void
onSettingsSaved?: () => void
onSuccess?: () => void
settingsOnly?: boolean
}
let {
open = $bindable(false),
gitRepoResourcePath,
uiState,
repoIndex,
currentGitSyncSettings,
onFilterUpdate,
onSettingsSaved,
onSuccess,
settingsOnly = false
}: Props = $props()
// Job state
let previewJobId = $state<string | null>(null)
let previewJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let isPreviewLoading = $state(false)
let previewError = $state('')
let applyJobId = $state<string | null>(null)
let applyJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let isApplying = $state(false)
let applyError = $state('')
// UI state
let showCliInstructions = $state(false)
let previewResult = $state<SyncResponse | SettingsResponse | null>(null)
let settingsApplied = $state(false)
let previewAttempted = $state(false)
// Helper functions to reduce type casting repetition
const getSettingsChanges = (result: SyncResponse | SettingsResponse | null) => {
if (!result) return { hasChanges: false, data: null, diff: null }
if (settingsOnly) {
const settingsResponse = result as SettingsResponse
return {
hasChanges: settingsResponse.hasChanges ?? false,
data: settingsResponse.local,
diff: settingsResponse
}
} else {
const syncResponse = result as SyncResponse
return {
hasChanges: syncResponse.settingsDiffResult?.hasChanges ?? false,
data: syncResponse.settingsDiffResult?.local,
diff: syncResponse.settingsDiffResult
}
}
}
const getWorkspaceChanges = (result: SyncResponse | SettingsResponse | null) => {
if (!result || settingsOnly) return { hasChanges: false, changes: [] }
const syncResponse = result as SyncResponse
return {
hasChanges: (syncResponse.changes?.length ?? 0) > 0,
changes: syncResponse.changes ?? []
}
}
// Auto-save settings after successful pull with settings updates
async function saveUpdatedSettings() {
if (!currentGitSyncSettings || repoIndex === undefined) return
try {
// Save only the specific repository that was updated
await currentGitSyncSettings.saveRepository(repoIndex)
onSettingsSaved?.()
} catch (error) {
console.error('Failed to save settings:', error)
sendUserToast('Failed to save updated settings', true)
}
}
// Reset state when modal opens/closes
$effect(() => {
if (!open) {
previewJobId = null
previewJobStatus = undefined
isPreviewLoading = false
previewError = ''
applyJobId = null
applyJobStatus = undefined
isApplying = false
applyError = ''
showCliInstructions = false
previewResult = null
settingsApplied = false
previewAttempted = false
} else if (settingsOnly && !previewResult && !isPreviewLoading && !previewAttempted) {
// Auto-trigger settings preview when modal opens in settings-only mode (only once)
executeJob(true, true)
}
})
// Execute job with dry run or actual execution
async function executeJob(isDryRun: boolean, settingsOnly: boolean = false) {
const isPreview = isDryRun
if (isPreview) {
isPreviewLoading = true
previewError = ''
previewResult = null
previewJobId = null
previewJobStatus = undefined
previewAttempted = true
} else {
isApplying = true
applyError = ''
applyJobId = null
applyJobStatus = undefined
}
try {
const workspace = $workspaceStore
if (!workspace) return
const payload = {
workspace_id: workspace,
repo_url_resource_path: gitRepoResourcePath,
dry_run: isDryRun,
pull: true,
only_wmill_yaml: settingsOnly,
settings_json: JSON.stringify(uiState),
use_promotion_overrides:
currentGitSyncSettings?.repositories?.[repoIndex!]?.use_individual_branch === true
}
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: payload,
skipPreprocessor: true
})
if (isPreview) {
previewJobId = jobId
previewJobStatus = 'running'
} else {
applyJobId = jobId
applyJobStatus = 'running'
}
// Use JobManager instead of tryEvery
const result = await jobManager.runWithProgress(() => Promise.resolve(jobId), {
workspace,
timeout: 60000,
timeoutMessage: `${isPreview ? 'Preview' : 'Apply'} job timed out after 60s`,
onProgress: (status) => {
if (isPreview) {
previewJobStatus = status.status
} else {
applyJobStatus = status.status
}
// Handle failure status
if (status.status === 'failure') {
if (isPreview) {
previewError = status.error || 'Preview failed'
} else {
applyError = status.error || 'Pull failed'
}
}
}
})
// Handle successful result
if (isPreview) {
if (previewJobStatus === 'success') {
previewResult = result as SyncResponse | SettingsResponse
}
} else {
if (applyJobStatus === 'success') {
const settingsData = (result as any)?.local
const hasSettingsChanges = settingsData && onFilterUpdate
if (hasSettingsChanges) {
onFilterUpdate(settingsData)
await saveUpdatedSettings()
}
onSuccess?.()
}
}
} catch (e) {
const errorMsg = e?.message || 'Operation failed'
if (isPreview) {
previewJobStatus = 'failure'
previewError = errorMsg
} else {
applyJobStatus = 'failure'
applyError = errorMsg
}
} finally {
if (isPreview) {
isPreviewLoading = false
} else {
isApplying = false
}
}
}
// Apply settings only (no job needed - we have the data from preview)
async function applySettingsOnly() {
isApplying = true
try {
const settingsChanges = getSettingsChanges(previewResult)
if (!settingsChanges.hasChanges) {
sendUserToast('No settings changes to apply', true)
return
}
if (!settingsChanges.data) {
sendUserToast('Settings data not available', true)
return
}
// Update the UI state with the new settings
if (onFilterUpdate) {
onFilterUpdate(settingsChanges.data)
}
// Save the updated settings
await saveUpdatedSettings()
if (settingsOnly) {
// Settings-only mode - we're done, onSuccess will handle the toast
onSuccess?.()
} else {
// Two-step flow - transition to step 2
settingsApplied = true
sendUserToast('Settings applied successfully. You can now review workspace changes.')
}
} catch (error: any) {
console.error('Failed to apply settings:', error)
sendUserToast('Failed to apply settings: ' + error.message, true)
} finally {
isApplying = false
}
}
</script>
<Modal
bind:open
title={settingsOnly ? 'Pull Settings from Git Repository' : 'Pull Workspace from Git Repository'}
class="sm:max-w-4xl"
cancelText={settingsOnly && !getSettingsChanges(previewResult).hasChanges ? 'Close' : 'Cancel'}
>
<div class="flex flex-col gap-4">
<!-- Description -->
<p class="text-sm text-secondary">
{#if settingsOnly}
Pull and apply settings changes from the Git repository to your workspace. This will update
your sync filter settings only.
{:else}
Pull and apply changes from the Git repository to your workspace. If settings changes are
detected, you can choose to pull just the settings or everything.
{/if}
</p>
<!-- Warning about overwrites - only show for full pulls, not settings-only -->
{#if !settingsOnly}
<Alert type="warning" title="This will overwrite local changes">
Pulling from the repository will overwrite any local changes to files that exist in the
repository. Make sure to preview the changes before applying.
</Alert>
{/if}
<!-- Preview section -->
{#if !previewResult}
<div class="flex justify-start pt-4">
<Button
size="md"
variant="accent"
onclick={() => {
previewAttempted = false // Allow manual retry
executeJob(true, settingsOnly)
}}
disabled={isPreviewLoading}
startIcon={{
icon: isPreviewLoading ? Loader2 : undefined,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
>
{isPreviewLoading ? 'Previewing...' : 'Preview changes'}
</Button>
</div>
{/if}
<!-- Job status for preview -->
{#if previewJobId}
<div class="flex items-center gap-2 text-xs text-primary">
{#if previewJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if previewJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if previewJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Preview job:
<a
target="_blank"
class="underline"
href={`/run/${previewJobId}?workspace=${$workspaceStore}`}
>
{previewJobId}
</a>
</div>
{/if}
<!-- Preview error -->
{#if previewError}
<Alert type="error" title="Preview failed">
{previewError}
</Alert>
{/if}
<!-- Preview results -->
{#if previewResult && !previewError}
{@const settingsChanges = getSettingsChanges(previewResult)}
{@const workspaceChanges = getWorkspaceChanges(previewResult)}
<div class="space-y-4">
<!-- Settings changes (always show first if present) -->
{#if settingsChanges.hasChanges && !settingsApplied}
<div>
<h4 class="text-sm font-semibold text-primary mb-2">
Filter Settings from Repository
<Badge color="blue" size="xs" class="ml-2">wmill.yaml</Badge>
</h4>
<div class="bg-surface-secondary rounded-lg p-4 space-y-1">
{#if settingsChanges.diff?.diff}
{#each Object.entries(settingsChanges.diff.diff) as [field, change]}
{@const fieldName = field
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase())}
{@const typedChange = change as { from: any; to: any }}
<div class="flex items-center gap-2 text-xs">
<span class="text-primary min-w-0 flex-shrink-0">{fieldName}:</span>
{#if Array.isArray(typedChange.from) && Array.isArray(typedChange.to)}
<span class="text-red-600"
>{typedChange.from.length === 0
? 'None'
: typedChange.from.join(', ')}</span
>
<span class="text-primary"></span>
<span class="text-green-600"
>{typedChange.to.length === 0 ? 'None' : typedChange.to.join(', ')}</span
>
{:else}
<span class="text-red-600">{typedChange.from}</span>
<span class="text-primary"></span>
<span class="text-green-600">{typedChange.to}</span>
{/if}
</div>
{/each}
{:else}
<div class="text-xs text-primary">
Settings changes detected but no detailed diff available.
</div>
{/if}
</div>
</div>
{/if}
<!-- No settings changes detected (settings-only mode) -->
{#if settingsOnly && !settingsChanges.hasChanges}
<div class="bg-surface-secondary rounded-lg p-3">
<div class="text-sm text-primary">
No settings changes detected. Your local sync filter settings are already up to date
with the repository. See the CLI instructions below on how to edit wmill.yaml
</div>
</div>
<div class="flex justify-start">
<Button
size="sm"
variant="accent"
onclick={() => {
previewAttempted = false
previewResult = null
executeJob(true, settingsOnly)
}}
disabled={isPreviewLoading}
startIcon={{
icon: isPreviewLoading ? Loader2 : undefined,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
>
{isPreviewLoading ? 'Previewing...' : 'Preview again'}
</Button>
</div>
{/if}
<!-- Workspace changes (show when no settings changes and there are workspace changes) -->
{#if !settingsOnly && !settingsChanges.hasChanges && workspaceChanges.hasChanges}
<div class={settingsChanges.hasChanges && settingsApplied ? 'border-t pt-4' : ''}>
<h4 class="text-sm font-semibold text-primary mb-2">Workspace changes to pull</h4>
{#if workspaceChanges.hasChanges}
<GitDiffPreview previewResult={previewResult as SyncResponse} />
{:else}
<div class="bg-surface-secondary rounded-lg p-3">
<div class="text-sm text-primary">No changes to pull from the repository.</div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Apply section (shown after successful preview) -->
{#if previewResult && !previewError}
{@const settingsChanges = getSettingsChanges(previewResult)}
{@const workspaceChanges = getWorkspaceChanges(previewResult)}
{#if settingsChanges.hasChanges || workspaceChanges.hasChanges}
<div class="border-t pt-4 mt-4">
{#if settingsChanges.hasChanges && workspaceChanges.hasChanges && !settingsApplied}
<!-- Step 1: Settings changes first when both are present -->
<div class="flex flex-col gap-3">
<div class="text-sm font-medium text-primary">Step 1 of 2: Apply settings changes</div
>
<div class="text-xs text-primary"
>Settings changes detected. Apply these first to ensure workspace content is pulled
with the correct configuration.</div
>
<div class="flex gap-2">
<Button
size="md"
onclick={applySettingsOnly}
disabled={isApplying}
startIcon={{
icon: isApplying ? Loader2 : Save,
classes: isApplying ? 'animate-spin' : ''
}}
>
{isApplying ? 'Applying...' : 'Apply settings'}
</Button>
</div>
</div>
{:else if settingsChanges.hasChanges && !workspaceChanges.hasChanges && !settingsApplied}
<!-- Only settings changes -->
<div class="flex gap-2">
<Button
size="md"
onclick={applySettingsOnly}
disabled={isApplying}
startIcon={{
icon: isApplying ? Loader2 : Save,
classes: isApplying ? 'animate-spin' : ''
}}
>
{isApplying ? 'Applying...' : 'Apply settings'}
</Button>
</div>
{:else if workspaceChanges.hasChanges && (!settingsChanges.hasChanges || settingsApplied)}
<!-- Step 2: Workspace changes (either no settings changes, or settings already applied) -->
<div class="flex flex-col gap-3">
{#if settingsApplied}
<div class="text-sm font-medium text-primary"
>Step 2 of 2: Pull Workspace Changes</div
>
<div class="text-xs text-green-600"
>✓ Settings applied successfully. Now you can pull the workspace changes.</div
>
{/if}
<div class="flex gap-2">
<Button
size="md"
onclick={() => executeJob(false, false)}
disabled={isApplying}
startIcon={{
icon: isApplying ? Loader2 : Save,
classes: isApplying ? 'animate-spin' : ''
}}
>
{isApplying ? 'Pulling...' : 'Pull from repository'}
</Button>
</div>
</div>
{:else}
<!-- No changes to pull -->
<div class="bg-surface-secondary rounded-lg p-3">
<div class="text-sm text-primary mb-3">No changes to pull from the repository.</div>
<Button
size="sm"
variant="accent"
onclick={() => {
previewAttempted = false
previewResult = null
executeJob(true, settingsOnly)
}}
disabled={isPreviewLoading}
startIcon={{
icon: isPreviewLoading ? Loader2 : undefined,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
>
{isPreviewLoading ? 'Previewing...' : 'Preview again'}
</Button>
</div>
{/if}
</div>
{/if}
{/if}
<!-- Job status for apply -->
{#if applyJobId}
<div class="flex items-center gap-2 text-xs text-primary">
{#if applyJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if applyJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if applyJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Pull job:
<a
target="_blank"
class="underline"
href={`/run/${applyJobId}?workspace=${$workspaceStore}`}
>
{applyJobId}
</a>
</div>
{/if}
<!-- Apply error -->
{#if applyError}
<Alert type="error" title="Pull failed">
{applyError}
</Alert>
{/if}
<!-- CLI Instructions (collapsible) -->
<div class="border-t pt-4 mt-4">
<button
class="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
onclick={() => (showCliInstructions = !showCliInstructions)}
>
<Terminal size={16} />
{#if settingsOnly}
<span>Update settings with CLI</span>
{:else}
<span>Update workspace with CLI</span>
{/if}
<Edit3 size={14} class="text-primary" />
{#if showCliInstructions}
<ChevronUp size={16} />
{:else}
<ChevronDown size={16} />
{/if}
</button>
{#if showCliInstructions}
<div class="mt-3 bg-surface-secondary rounded-lg p-3">
{#if settingsOnly}
<div class="text-xs text-primary mb-2">
Filter settings are sourced from the <code class="bg-surface px-1 py-0.5 rounded"
>wmill.yaml</code
>
file in your git repository. To modify them, edit the file in your repository, commit the
changes, and sync using the commands below. Learn more about
<a
href="https://www.windmill.dev/docs/advanced/cli/sync#wmillyaml"
target="_blank"
rel="noopener noreferrer">the wmill.yaml format</a
>.
</div>
{/if}
<pre class="text-xs bg-surface p-3 rounded overflow-x-auto whitespace-pre-wrap break-all">
# Make sure your repo is up to date
git pull
{#if !settingsOnly}# Push from git repository to workspace
wmill sync push --workspace {$workspaceStore} --repository {gitRepoResourcePath}
{:else}# Edit wmill.yaml file
vim wmill.yaml
git add wmill.yaml
{/if}# Commit changes
git commit
git push
{#if settingsOnly}
# Push settings only from git repository or click the pull settings button above{#if currentGitSyncSettings?.repositories?.[repoIndex!]?.use_individual_branch}
wmill gitsync-settings push --workspace {$workspaceStore} --repository {gitRepoResourcePath} --promotion main{:else}
wmill gitsync-settings push --workspace {$workspaceStore} --repository {gitRepoResourcePath}{/if}{/if}</pre
>
{#if currentGitSyncSettings?.repositories?.[repoIndex!]?.use_individual_branch && settingsOnly}
<div class="text-xs text-primary mt-3">
<div class="font-medium mb-1">Promotion Mode Configuration:</div>
<div class="flex items-center gap-2">
<span
>You can add promotion-specific overrides in your <code
class="bg-surface px-1 py-0.5 rounded">wmill.yaml</code
> file:</span
>
</div>
<pre class="text-xs bg-surface p-2 rounded mt-2 overflow-x-auto"
>gitBranches:
main:
promotionOverrides:
# Add your promotion-specific settings here
</pre>
</div>
{/if}
</div>
{/if}
</div>
</div>
</Modal>