mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-26 16:02:36 +00:00
feat: git sync improvements (#6182)
* init checkpoint * ui second pass... * round 1 backend + saving settings + detecting changes... * checkpoint * fix openapi * saving + correct wmill.yaml diff * cli refactor * cli and tests refactor done * cli multi workspace support * cli support skip core types to align with ui * new test framework * sqlx * openapi spec * frontend * sync + settings changes * some fixes * some fixes * security: Remove hardcoded EE license key, use environment variable only - Remove hardcoded license key from containerized test backend - Environment variable EE_LICENSE_KEY now required for EE features - License key no longer stored in database during tests * sqlx * tests * fixing tests * fix tests * checkpoint * checkpoint * cli build * frontend - cli exchange * settings match * ee repo ref * npm check * openapi * tests * checkpoint * cli + tests * reset to preview on changes * merge issue ee * cleanup * hubscript * simplifications * ee repo ref * cli fixes * fix sync and add tests * extra test * git sync settings / key change aware * ee-repo ref * ee-repo ref * ee repo ref * ee ref * review 1 * ee ref * Update frontend/src/lib/components/PullGitRepoPopover.svelte Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * Update frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * ee ref * remove extra includes from ui --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
ellipsis-dev[bot]
parent
574f14b349
commit
27bf4e34d8
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
type DiffResult = {
|
||||
added: string[]
|
||||
deleted: string[]
|
||||
modified: string[]
|
||||
repoWmillYaml?: string
|
||||
yamlModified?: boolean
|
||||
}
|
||||
|
||||
let { previewResult } = $props<{
|
||||
previewResult: DiffResult | undefined
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<div class="border rounded p-2 text-xs max-h-40 overflow-y-auto bg-surface-secondary">
|
||||
<div class="font-semibold text-[11px] mb-1 text-tertiary">Preview of changes:</div>
|
||||
{#if !previewResult?.added?.length && !previewResult?.deleted?.length && !previewResult?.modified?.length && !previewResult?.yamlModified}
|
||||
<div class="mt-2 text-tertiary">No changes found! The workspace is up to date.</div>
|
||||
{:else}
|
||||
{#if previewResult?.yamlModified}
|
||||
<div class="mt-2">
|
||||
<div class="text-yellow-600">Modified:</div>
|
||||
<ul class="list-disc list-inside">
|
||||
<li>wmill.yaml (Git sync settings)</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if previewResult?.added?.length}
|
||||
<div class="mt-2">
|
||||
<div class="text-green-600">Added:</div>
|
||||
<ul class="list-disc list-inside">
|
||||
{#each previewResult.added as file}
|
||||
<li>
|
||||
{file}{!file.includes('.') ? ' (dir)' : ''}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if previewResult?.deleted?.length}
|
||||
<div class="mt-2">
|
||||
<div class="text-red-600">Deleted:</div>
|
||||
<ul class="list-disc list-inside">
|
||||
{#each previewResult.deleted as file}
|
||||
<li>
|
||||
{file}{!file.includes('.') ? ' (dir)' : ''}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if previewResult?.modified?.length}
|
||||
<div class="mt-2">
|
||||
<div class="text-yellow-600">Modified:</div>
|
||||
<ul class="list-disc list-inside">
|
||||
{#each previewResult.modified as file}
|
||||
<li>
|
||||
{file}{!file.includes('.') ? ' (dir)' : ''}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,454 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import { Alert } from '$lib/components/common'
|
||||
import {
|
||||
Loader2,
|
||||
Eye,
|
||||
Save,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
UploadCloud,
|
||||
AlertTriangle,
|
||||
Terminal,
|
||||
ChevronDown,
|
||||
ChevronUp
|
||||
} from 'lucide-svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import hubPaths from '$lib/hubPaths.json'
|
||||
import { JobService } from '$lib/gen'
|
||||
import { tryEvery } from '$lib/utils'
|
||||
|
||||
// Types for git sync result
|
||||
interface GitSyncChange {
|
||||
type: 'added' | 'deleted' | 'modified'
|
||||
path: string
|
||||
}
|
||||
|
||||
interface GitSyncResult {
|
||||
changes: GitSyncChange[]
|
||||
}
|
||||
import GitDiffPreview from './GitDiffPreview.svelte'
|
||||
import { page } from '$app/stores'
|
||||
|
||||
let { gitRepoResourcePath, branchName, uiState } = $props<{
|
||||
gitRepoResourcePath: string
|
||||
branchName?: string
|
||||
uiState: {
|
||||
include_path: string[]
|
||||
exclude_path: string[]
|
||||
extra_include_path: string[]
|
||||
include_type: string[]
|
||||
}
|
||||
}>()
|
||||
|
||||
let _branchName = $state(branchName ?? '')
|
||||
let previewResult = $state<
|
||||
| {
|
||||
added: string[]
|
||||
deleted: string[]
|
||||
modified: string[]
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
let isPreviewLoading = $state(false)
|
||||
let isInitializing = $state(false)
|
||||
let initResult = $state<{ success: boolean; message: string | undefined } | null>(null)
|
||||
let initGitRepoPopover = $state<{ open: () => void; close: () => void } | null>(null)
|
||||
let previewJobId = $state<string | null>(null)
|
||||
let previewJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
|
||||
let pushJobId = $state<string | null>(null)
|
||||
let pushJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
|
||||
let isCliInfoExpanded = $state(false)
|
||||
|
||||
async function previewChanges() {
|
||||
console.log('Previewing changes for repo:', gitRepoResourcePath)
|
||||
isPreviewLoading = true
|
||||
previewJobId = null
|
||||
previewJobStatus = undefined
|
||||
try {
|
||||
const workspace = $workspaceStore
|
||||
if (!workspace) {
|
||||
previewResult = undefined
|
||||
isPreviewLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
// Pass UI state directly as JSON to CLI
|
||||
const payloadObj = {
|
||||
workspace_id: workspace,
|
||||
repo_url_resource_path: gitRepoResourcePath,
|
||||
branch_to_push: _branchName,
|
||||
dry_run: true,
|
||||
settings_json: JSON.stringify(uiState)
|
||||
}
|
||||
|
||||
const jobId = await JobService.runScriptByPath({
|
||||
workspace,
|
||||
path: hubPaths.gitInitRepo,
|
||||
requestBody: payloadObj,
|
||||
skipPreprocessor: true
|
||||
})
|
||||
previewJobId = jobId
|
||||
previewJobStatus = 'running'
|
||||
|
||||
// Wait for job completion (polling)
|
||||
let jobSuccess = false
|
||||
await tryEvery({
|
||||
tryCode: async () => {
|
||||
const testResult = await JobService.getCompletedJob({
|
||||
workspace,
|
||||
id: jobId
|
||||
})
|
||||
jobSuccess = !!testResult.success
|
||||
},
|
||||
timeoutCode: async () => {
|
||||
try {
|
||||
await JobService.cancelQueuedJob({
|
||||
workspace,
|
||||
id: jobId,
|
||||
requestBody: {
|
||||
reason: 'Preview job timed out after 15s'
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
},
|
||||
interval: 500,
|
||||
timeout: 15000
|
||||
})
|
||||
|
||||
if (jobSuccess) {
|
||||
const result = await JobService.getCompletedJobResult({
|
||||
workspace,
|
||||
id: jobId
|
||||
})
|
||||
console.log('Preview result:', result)
|
||||
|
||||
// Convert new CLI format to expected format
|
||||
const added: string[] = []
|
||||
const deleted: string[] = []
|
||||
const modified: string[] = []
|
||||
|
||||
if (
|
||||
result &&
|
||||
(result as GitSyncResult).changes &&
|
||||
Array.isArray((result as GitSyncResult).changes)
|
||||
) {
|
||||
for (const change of (result as GitSyncResult).changes) {
|
||||
if (change.type === 'added') {
|
||||
added.push(change.path)
|
||||
} else if (change.type === 'deleted') {
|
||||
deleted.push(change.path)
|
||||
} else if (change.type === 'modified') {
|
||||
modified.push(change.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previewResult = { added, deleted, modified }
|
||||
previewJobStatus = 'success'
|
||||
} else {
|
||||
previewResult = undefined
|
||||
previewJobStatus = 'failure'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to preview changes:', error)
|
||||
previewResult = undefined
|
||||
previewJobStatus = 'failure'
|
||||
} finally {
|
||||
isPreviewLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeRepo() {
|
||||
const workspace = $workspaceStore
|
||||
if (!workspace) return
|
||||
console.log('Initializing repo:', gitRepoResourcePath, 'in workspace:', workspace)
|
||||
isInitializing = true
|
||||
initResult = null
|
||||
pushJobId = null
|
||||
pushJobStatus = undefined
|
||||
try {
|
||||
// Pass UI state directly as JSON to CLI
|
||||
const jobId = await JobService.runScriptByPath({
|
||||
workspace,
|
||||
path: hubPaths.gitInitRepo,
|
||||
requestBody: {
|
||||
workspace_id: workspace,
|
||||
repo_url_resource_path: gitRepoResourcePath,
|
||||
branch_to_push: _branchName,
|
||||
settings_json: JSON.stringify(uiState)
|
||||
},
|
||||
skipPreprocessor: true
|
||||
})
|
||||
pushJobId = jobId
|
||||
pushJobStatus = 'running'
|
||||
|
||||
let jobSuccess = false
|
||||
await tryEvery({
|
||||
tryCode: async () => {
|
||||
const testResult = await JobService.getCompletedJob({
|
||||
workspace,
|
||||
id: jobId
|
||||
})
|
||||
jobSuccess = !!testResult.success
|
||||
},
|
||||
timeoutCode: async () => {
|
||||
try {
|
||||
await JobService.cancelQueuedJob({
|
||||
workspace,
|
||||
id: jobId,
|
||||
requestBody: {
|
||||
reason: 'Push job timed out after 5s'
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
},
|
||||
interval: 500,
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
pushJobStatus = jobSuccess ? 'success' : 'failure'
|
||||
initResult = {
|
||||
success: jobSuccess,
|
||||
message: jobSuccess ? undefined : 'Failed to initialize repository.'
|
||||
}
|
||||
|
||||
// Reset popover state after successful push
|
||||
if (jobSuccess) {
|
||||
setTimeout(() => {
|
||||
previewResult = undefined
|
||||
pushJobId = null
|
||||
pushJobStatus = undefined
|
||||
initResult = null
|
||||
initGitRepoPopover?.close()
|
||||
}, 1500) // Small delay to show success state
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize repo:', error)
|
||||
pushJobStatus = 'failure'
|
||||
initResult = { success: false, message: 'Failed to initialize repository.' }
|
||||
} finally {
|
||||
isInitializing = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover
|
||||
bind:this={initGitRepoPopover}
|
||||
floatingConfig={{
|
||||
placement: 'top-start',
|
||||
strategy: 'fixed',
|
||||
flip: false,
|
||||
shift: true
|
||||
}}
|
||||
contentClasses="p-4 w-1/3"
|
||||
>
|
||||
<svelte:fragment slot="trigger">
|
||||
<Button
|
||||
color="dark"
|
||||
size="sm"
|
||||
nonCaptureEvent
|
||||
onclick={initGitRepoPopover?.open}
|
||||
startIcon={{ icon: UploadCloud }}
|
||||
>
|
||||
Push workspace to Git repo
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="content" let:close>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-lg font-semibold">Push workspace to Git repository</h3>
|
||||
<div class="prose max-w-none text-2xs text-tertiary">
|
||||
This action will push all workspace objects that match your current filter settings to the
|
||||
selected branch in your Git repository. <span
|
||||
class="text-orange-600 flex items-center gap-1"
|
||||
><AlertTriangle size={14} /> Any existing content in the branch will be replaced with the
|
||||
filtered workspace content.</span
|
||||
>
|
||||
|
||||
<!-- Collapsible CLI Info Section -->
|
||||
<div class="mt-2 border rounded-md">
|
||||
<button
|
||||
class="w-full flex items-center justify-between p-1.5 bg-surface-secondary hover:bg-surface-hover"
|
||||
onclick={() => (isCliInfoExpanded = !isCliInfoExpanded)}
|
||||
>
|
||||
<span class="font-medium flex items-center gap-2">
|
||||
<Terminal size={14} />
|
||||
Windmill CLI to pull from Windmill and push to git
|
||||
</span>
|
||||
{#if isCliInfoExpanded}
|
||||
<ChevronUp size={16} />
|
||||
{:else}
|
||||
<ChevronDown size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if isCliInfoExpanded}
|
||||
<div class="p-1 bg-surface-tertiary">
|
||||
<div class="text-2xs mb-2">
|
||||
Not familiar with Windmill CLI? <a
|
||||
href="https://www.windmill.dev/docs/advanced/cli/sync"
|
||||
class="text-blue-500 hover:text-blue-600 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">Check out the docs</a
|
||||
>
|
||||
</div>
|
||||
<div class="font-mono text-2xs">
|
||||
<pre class="overflow-auto max-h-60"
|
||||
><code
|
||||
>npm install -g windmill-cli
|
||||
wmill workspace add {$workspaceStore} {$workspaceStore} {`${$page.url.protocol}//${$page.url.hostname}/`}
|
||||
wmill init
|
||||
# adjust wmill.yaml file configuraton as needed
|
||||
wmill sync pull
|
||||
git add -A
|
||||
git commit -m 'Initial commit'
|
||||
git push</code
|
||||
></pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="branch-name" class="text-sm font-medium">Push to new branch (optional)</label>
|
||||
<div class="prose max-w-none text-2xs text-tertiary">
|
||||
Enter a new branch name to push to (e.g so you can merge back into main with a pull
|
||||
request). If left blank, the default branch from the git repository resource will be used.
|
||||
</div>
|
||||
<div class="flex flex-col w-1/4">
|
||||
<input
|
||||
id="branch-name"
|
||||
type="text"
|
||||
bind:value={_branchName}
|
||||
class="border rounded px-2 py-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if previewResult}
|
||||
<GitDiffPreview {previewResult} />
|
||||
{/if}
|
||||
|
||||
{#if previewJobId}
|
||||
<div class="flex items-center gap-2 text-xs text-tertiary">
|
||||
{#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}
|
||||
|
||||
{#if pushJobId}
|
||||
<div class="flex items-center gap-2 text-xs text-tertiary">
|
||||
{#if pushJobStatus === 'running'}
|
||||
<Loader2 class="animate-spin" size={14} />
|
||||
{:else if pushJobStatus === 'success'}
|
||||
<CheckCircle2 size={14} class="text-green-600" />
|
||||
{:else if pushJobStatus === 'failure'}
|
||||
<XCircle size={14} class="text-red-700" />
|
||||
{/if}
|
||||
Push job:
|
||||
<a
|
||||
target="_blank"
|
||||
class="underline"
|
||||
href={`/run/${pushJobId}?workspace=${$workspaceStore}`}
|
||||
>
|
||||
{pushJobId}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action row: Cancel on left, Preview/Confirm on right -->
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
previewResult = undefined
|
||||
previewJobId = null
|
||||
previewJobStatus = undefined
|
||||
pushJobId = null
|
||||
pushJobStatus = undefined
|
||||
initResult = null
|
||||
close()
|
||||
}}
|
||||
disabled={isPreviewLoading || isInitializing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
{#if !previewResult}
|
||||
<Button
|
||||
size="xs"
|
||||
on:click={previewChanges}
|
||||
disabled={isPreviewLoading || isInitializing}
|
||||
startIcon={{
|
||||
icon: isPreviewLoading ? Loader2 : Eye,
|
||||
classes: isPreviewLoading ? 'animate-spin' : ''
|
||||
}}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
size="xs"
|
||||
on:click={previewChanges}
|
||||
disabled={isPreviewLoading || isInitializing}
|
||||
startIcon={{
|
||||
icon: isPreviewLoading ? Loader2 : Eye,
|
||||
classes: isPreviewLoading ? 'animate-spin' : ''
|
||||
}}
|
||||
title="Preview changes again"
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
{#if previewResult.added?.length || previewResult.deleted?.length || previewResult.modified?.length}
|
||||
<Button
|
||||
color="red"
|
||||
size="xs"
|
||||
on:click={initializeRepo}
|
||||
disabled={isPreviewLoading || isInitializing}
|
||||
startIcon={{ icon: Save }}
|
||||
title="Initialize Git Repo"
|
||||
>
|
||||
Push
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if initResult?.message}
|
||||
<div class="mt-2">
|
||||
<Alert
|
||||
type={initResult.success ? 'success' : 'error'}
|
||||
title={initResult.success ? 'Success' : 'Error'}
|
||||
size="xs"
|
||||
>
|
||||
{initResult.message}
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Popover>
|
||||
@@ -0,0 +1,418 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import {
|
||||
Loader2,
|
||||
Eye,
|
||||
Save,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
DownloadCloud,
|
||||
AlertTriangle,
|
||||
Terminal,
|
||||
ChevronDown,
|
||||
ChevronUp
|
||||
} from 'lucide-svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import hubPaths from '$lib/hubPaths.json'
|
||||
import { JobService } from '$lib/gen'
|
||||
import { tryEvery } from '$lib/utils'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import GitDiffPreview from './GitDiffPreview.svelte'
|
||||
import { page } from '$app/stores'
|
||||
|
||||
// Types for git sync result
|
||||
interface GitSyncChange {
|
||||
type: 'added' | 'deleted' | 'modified'
|
||||
path: string
|
||||
}
|
||||
|
||||
interface GitSyncResult {
|
||||
changes: GitSyncChange[]
|
||||
}
|
||||
|
||||
let { gitRepoResourcePath, uiState, onFilterUpdate } = $props<{
|
||||
gitRepoResourcePath: string
|
||||
uiState: {
|
||||
include_path: string[]
|
||||
exclude_path: string[]
|
||||
extra_include_path: string[]
|
||||
include_type: string[]
|
||||
}
|
||||
onFilterUpdate: (filters: {
|
||||
include_path: string[]
|
||||
exclude_path: string[]
|
||||
extra_include_path: string[]
|
||||
include_type: string[]
|
||||
}) => void
|
||||
}>()
|
||||
|
||||
type PreviewResult = {
|
||||
added: string[]
|
||||
deleted: string[]
|
||||
modified: string[]
|
||||
}
|
||||
|
||||
let previewResult = $state<PreviewResult | undefined>(undefined)
|
||||
|
||||
let isPreviewLoading = $state(false)
|
||||
let isPulling = $state(false)
|
||||
let pullGitRepoPopover = $state<{ open: () => void; close: () => void } | null>(null)
|
||||
let jobStatus = $state<{
|
||||
id: string | null
|
||||
status: 'running' | 'success' | 'failure' | undefined
|
||||
error?: string
|
||||
type: 'preview' | 'pull'
|
||||
}>({
|
||||
id: null,
|
||||
status: undefined,
|
||||
type: 'preview'
|
||||
})
|
||||
|
||||
let isCliInfoExpanded = $state(false)
|
||||
|
||||
async function handleJobCompletion(jobId: string, workspace: string): Promise<boolean> {
|
||||
let success = false
|
||||
await tryEvery({
|
||||
tryCode: async () => {
|
||||
const result = await JobService.getCompletedJob({
|
||||
workspace,
|
||||
id: jobId
|
||||
})
|
||||
success = !!result.success
|
||||
},
|
||||
timeoutCode: async () => {
|
||||
try {
|
||||
await JobService.cancelQueuedJob({
|
||||
workspace,
|
||||
id: jobId,
|
||||
requestBody: {
|
||||
reason: 'Job timed out after 5s'
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
},
|
||||
interval: 500,
|
||||
timeout: 10000
|
||||
})
|
||||
return success
|
||||
}
|
||||
|
||||
async function previewChanges() {
|
||||
const workspace = $workspaceStore
|
||||
if (!workspace) return
|
||||
|
||||
console.log('Previewing changes for repo:', gitRepoResourcePath)
|
||||
isPreviewLoading = true
|
||||
jobStatus = { id: null, status: undefined, type: 'preview' }
|
||||
|
||||
try {
|
||||
// Always use the simplified JSON approach
|
||||
const jobId = await JobService.runScriptByPath({
|
||||
workspace,
|
||||
path: hubPaths.gitInitRepo,
|
||||
requestBody: {
|
||||
workspace_id: workspace,
|
||||
repo_url_resource_path: gitRepoResourcePath,
|
||||
dry_run: true,
|
||||
pull: true,
|
||||
only_wmill_yaml: false,
|
||||
settings_json: JSON.stringify(uiState)
|
||||
},
|
||||
skipPreprocessor: true
|
||||
})
|
||||
|
||||
jobStatus = { id: jobId, status: 'running', type: 'preview' }
|
||||
const success = await handleJobCompletion(jobId, workspace)
|
||||
|
||||
if (success) {
|
||||
const rawResult = await JobService.getCompletedJobResult({ workspace, id: jobId })
|
||||
console.log('Preview result:', rawResult)
|
||||
|
||||
// Convert new CLI format to expected format
|
||||
const added: string[] = []
|
||||
const deleted: string[] = []
|
||||
const modified: string[] = []
|
||||
|
||||
if (
|
||||
rawResult &&
|
||||
(rawResult as GitSyncResult).changes &&
|
||||
Array.isArray((rawResult as GitSyncResult).changes)
|
||||
) {
|
||||
for (const change of (rawResult as GitSyncResult).changes) {
|
||||
if (change.type === 'added') {
|
||||
added.push(change.path)
|
||||
} else if (change.type === 'deleted') {
|
||||
deleted.push(change.path)
|
||||
} else if (change.type === 'modified') {
|
||||
modified.push(change.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For full sync mode, just use the CLI results directly
|
||||
// The CLI already handles wmill.yaml changes with --include-wmill-yaml flag
|
||||
previewResult = { added, deleted, modified }
|
||||
jobStatus.status = 'success'
|
||||
} else {
|
||||
previewResult = undefined
|
||||
jobStatus.status = 'failure'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to preview changes:', error)
|
||||
previewResult = undefined
|
||||
jobStatus = {
|
||||
...jobStatus,
|
||||
status: 'failure',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
} finally {
|
||||
isPreviewLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function pullFromRepo() {
|
||||
const workspace = $workspaceStore
|
||||
if (!workspace) return
|
||||
|
||||
console.log('Pulling from repo:', gitRepoResourcePath)
|
||||
isPulling = true
|
||||
jobStatus = { id: null, status: undefined, type: 'pull' }
|
||||
|
||||
try {
|
||||
// Use init git repo script with dry_run: false (actual pull operation)
|
||||
// The script will read wmill.yaml directly from the cloned repo, no need to pass settings
|
||||
const jobId = await JobService.runScriptByPath({
|
||||
workspace,
|
||||
path: hubPaths.gitInitRepo,
|
||||
requestBody: {
|
||||
workspace_id: workspace,
|
||||
repo_url_resource_path: gitRepoResourcePath,
|
||||
dry_run: false,
|
||||
branch_to_push: '',
|
||||
only_wmill_yaml: false,
|
||||
pull: true,
|
||||
settings_json: undefined // Let script use wmill.yaml from repo
|
||||
},
|
||||
skipPreprocessor: true
|
||||
})
|
||||
|
||||
jobStatus = { id: jobId, status: 'running', type: 'pull' }
|
||||
const success = await handleJobCompletion(jobId, workspace)
|
||||
jobStatus.status = success ? 'success' : 'failure'
|
||||
|
||||
if (success) {
|
||||
// Get the result which should contain the local git repo settings as JSON
|
||||
const result = (await JobService.getCompletedJobResult({ workspace, id: jobId })) as any
|
||||
console.log('Pull result:', result)
|
||||
|
||||
// Apply the settings from the sync operation result to the UI
|
||||
if (result?.settings_json) {
|
||||
// Directly update the UI state with the JSON result - no YAML conversion needed!
|
||||
const settingsJson = result.settings_json as {
|
||||
include_path: string[]
|
||||
exclude_path?: string[]
|
||||
extra_include_path?: string[]
|
||||
include_type: string[]
|
||||
}
|
||||
onFilterUpdate({
|
||||
include_path: settingsJson.include_path || ['f/**'],
|
||||
exclude_path: settingsJson.exclude_path || [],
|
||||
extra_include_path: settingsJson.extra_include_path || [],
|
||||
include_type: settingsJson.include_type || ['script', 'flow', 'app', 'folder']
|
||||
})
|
||||
sendUserToast('Successfully pulled workspace content from repository')
|
||||
|
||||
// Reset popover state after successful pull
|
||||
previewResult = undefined
|
||||
jobStatus = { id: null, status: undefined, type: 'preview' }
|
||||
pullGitRepoPopover?.close()
|
||||
} else {
|
||||
console.warn('No settings_json returned from pull operation')
|
||||
sendUserToast('Pull completed but could not update filter settings', true)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pull from repo:', error)
|
||||
jobStatus = {
|
||||
...jobStatus,
|
||||
status: 'failure',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
} finally {
|
||||
isPulling = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover
|
||||
bind:this={pullGitRepoPopover}
|
||||
floatingConfig={{
|
||||
placement: 'top-start',
|
||||
strategy: 'fixed',
|
||||
flip: false,
|
||||
shift: true
|
||||
}}
|
||||
contentClasses="p-4 w-1/3"
|
||||
>
|
||||
<svelte:fragment slot="trigger">
|
||||
<Button
|
||||
color="dark"
|
||||
size="sm"
|
||||
nonCaptureEvent
|
||||
onclick={pullGitRepoPopover?.open}
|
||||
startIcon={{ icon: DownloadCloud }}
|
||||
>
|
||||
Pull workspace from Git repo
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="content" let:close>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-lg font-semibold">Pull workspace from Git repository</h3>
|
||||
<div class="prose max-w-none text-2xs text-tertiary">
|
||||
This action will pull all workspace objects from your Git repository according to the
|
||||
filters set in the Git repository wmill.yaml file and apply those filter settings to the
|
||||
workspace.
|
||||
<span class="text-orange-600 flex items-center gap-1">
|
||||
<AlertTriangle size={14} /> This will overwrite your current workspace content and Git sync
|
||||
filter settings with the content from the Git repository.
|
||||
</span>
|
||||
|
||||
<!-- Collapsible CLI Info Section -->
|
||||
<div class="mt-2 border rounded-md">
|
||||
<button
|
||||
class="w-full flex items-center justify-between p-1.5 bg-surface-secondary hover:bg-surface-hover"
|
||||
onclick={() => (isCliInfoExpanded = !isCliInfoExpanded)}
|
||||
>
|
||||
<span class="font-medium flex items-center gap-2">
|
||||
<Terminal size={14} />
|
||||
Windmill CLI to push local files to Windmill
|
||||
</span>
|
||||
{#if isCliInfoExpanded}
|
||||
<ChevronUp size={16} />
|
||||
{:else}
|
||||
<ChevronDown size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if isCliInfoExpanded}
|
||||
<div class="p-1 bg-surface-tertiary">
|
||||
<div class="text-2xs mb-2">
|
||||
Not familiar with Windmill CLI? <a
|
||||
href="https://www.windmill.dev/docs/advanced/cli/sync"
|
||||
class="text-blue-500 hover:text-blue-600 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">Check out the docs</a
|
||||
>
|
||||
</div>
|
||||
<div class="font-mono text-2xs">
|
||||
<pre class="overflow-auto max-h-60"
|
||||
><code
|
||||
>npm install -g windmill-cli
|
||||
# Clone your git repository
|
||||
git clone $REPO_URL
|
||||
cd $REPO_NAME
|
||||
# Configure Windmill CLI
|
||||
wmill workspace add {$workspaceStore} {$workspaceStore} {`${$page.url.protocol}//${$page.url.hostname}/`}
|
||||
# Push the content to Windmill
|
||||
wmill sync push --yes
|
||||
# Optional: add --skip-secrets --skip-variables --skip-resources flags as needed</code
|
||||
></pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if previewResult}
|
||||
<GitDiffPreview {previewResult} />
|
||||
{/if}
|
||||
|
||||
{#if jobStatus.id}
|
||||
<div class="flex items-center gap-2 text-xs text-tertiary">
|
||||
{#if jobStatus.status === 'running'}
|
||||
<Loader2 class="animate-spin" size={14} />
|
||||
{:else if jobStatus.status === 'success'}
|
||||
<CheckCircle2 size={14} class="text-green-600" />
|
||||
{:else if jobStatus.status === 'failure'}
|
||||
<XCircle size={14} class="text-red-700" />
|
||||
{/if}
|
||||
{jobStatus.type === 'preview' ? 'Preview' : 'Pull'} job:
|
||||
<a
|
||||
target="_blank"
|
||||
class="underline"
|
||||
href={`/run/${jobStatus.id}?workspace=${$workspaceStore}`}
|
||||
>
|
||||
{jobStatus.id}
|
||||
</a>
|
||||
</div>
|
||||
{#if jobStatus.error}
|
||||
<div class="text-xs text-red-600">{jobStatus.error}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
previewResult = undefined
|
||||
jobStatus = { id: null, status: undefined, type: 'preview' }
|
||||
close()
|
||||
}}
|
||||
disabled={isPreviewLoading || isPulling}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
{#if !previewResult}
|
||||
<Button
|
||||
size="xs"
|
||||
on:click={previewChanges}
|
||||
disabled={isPreviewLoading || isPulling}
|
||||
startIcon={{
|
||||
icon: isPreviewLoading ? Loader2 : Eye,
|
||||
classes: isPreviewLoading ? 'animate-spin' : ''
|
||||
}}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
size="xs"
|
||||
on:click={previewChanges}
|
||||
disabled={isPreviewLoading || isPulling}
|
||||
startIcon={{
|
||||
icon: isPreviewLoading ? Loader2 : Eye,
|
||||
classes: isPreviewLoading ? 'animate-spin' : ''
|
||||
}}
|
||||
title="Preview changes again"
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
{#if previewResult.added?.length || previewResult.deleted?.length || previewResult.modified?.length}
|
||||
<Button
|
||||
color="red"
|
||||
size="xs"
|
||||
on:click={pullFromRepo}
|
||||
disabled={isPreviewLoading || isPulling}
|
||||
startIcon={{
|
||||
icon: isPulling ? Loader2 : Save,
|
||||
classes: isPulling ? 'animate-spin' : ''
|
||||
}}
|
||||
>
|
||||
{isPulling ? 'Pulling...' : 'Pull'}
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Popover>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { Plus, X } from 'lucide-svelte'
|
||||
|
||||
let {
|
||||
title = '',
|
||||
tooltip = undefined,
|
||||
items = $bindable([] as string[]),
|
||||
placeholder = 'Add filter (e.g. f/**)'
|
||||
} = $props()
|
||||
|
||||
let newItem = $state('')
|
||||
let inputRef: HTMLInputElement | null = $state(null)
|
||||
|
||||
function addItem() {
|
||||
const value = newItem.trim()
|
||||
if (value && !items.includes(value)) {
|
||||
items = [...items, value]
|
||||
newItem = ''
|
||||
inputRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function removeItem(idx: number) {
|
||||
items = items.filter((_, i) => i !== idx)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h4 class="font-semibold text-sm">{title}</h4>
|
||||
{#if tooltip}
|
||||
{@render tooltip?.()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 items-center mb-1">
|
||||
{#each items as item, idx (item)}
|
||||
<span class="flex items-center bg-gray-100 rounded-full px-3 py-1 text-xs text-gray-700">
|
||||
{item}
|
||||
<button
|
||||
class="ml-2 text-gray-400 hover:text-red-500 focus:outline-none"
|
||||
onclick={() => removeItem(idx)}
|
||||
aria-label="Remove filter"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
<input
|
||||
bind:this={inputRef}
|
||||
class="border border-gray-300 rounded-full px-3 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
{placeholder}
|
||||
value={newItem}
|
||||
oninput={(e) => (newItem = e.currentTarget.value)}
|
||||
onkeydown={(e) => e.key === 'Enter' && (addItem(), e.preventDefault())}
|
||||
/>
|
||||
<button
|
||||
class="ml-1 text-primary hover:bg-primary/10 rounded-full p-1"
|
||||
onclick={addItem}
|
||||
aria-label="Add filter"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,877 @@
|
||||
<script lang="ts">
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { Filter, Save, Eye, Loader2, CheckCircle2, XCircle, Check } from 'lucide-svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import yaml from 'js-yaml'
|
||||
import hubPaths from '$lib/hubPaths.json'
|
||||
import { JobService } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import FilterList from './FilterList.svelte'
|
||||
import { Tabs, Tab } from '$lib/components/common'
|
||||
|
||||
type ObjectType =
|
||||
| 'script'
|
||||
| 'flow'
|
||||
| 'app'
|
||||
| 'folder'
|
||||
| 'resource'
|
||||
| 'variable'
|
||||
| 'secret'
|
||||
| 'resourcetype'
|
||||
| 'schedule'
|
||||
| 'user'
|
||||
| 'group'
|
||||
| 'trigger'
|
||||
| 'settings'
|
||||
| 'key'
|
||||
|
||||
type GitSyncTypeMap = {
|
||||
scripts: boolean
|
||||
flows: boolean
|
||||
apps: boolean
|
||||
folders: boolean
|
||||
resourceTypes: boolean
|
||||
resources: boolean
|
||||
variables: boolean
|
||||
secrets: boolean
|
||||
schedules: boolean
|
||||
users: boolean
|
||||
groups: boolean
|
||||
triggers: boolean
|
||||
settings: boolean
|
||||
key: boolean
|
||||
}
|
||||
|
||||
type PreviewResult = {
|
||||
diff?: { [key: string]: { from: any; to: any } }
|
||||
hasChanges?: boolean
|
||||
isInitialSetup?: boolean
|
||||
message?: string
|
||||
local?: {
|
||||
include_path: string[]
|
||||
exclude_path: string[]
|
||||
extra_include_path: string[]
|
||||
include_type: ObjectType[]
|
||||
}
|
||||
backend?: {
|
||||
include_path: string[]
|
||||
exclude_path: string[]
|
||||
extra_include_path: string[]
|
||||
include_type: ObjectType[]
|
||||
}
|
||||
}
|
||||
|
||||
let {
|
||||
git_repo_resource_path = $bindable(''),
|
||||
include_path = $bindable(['f/**']),
|
||||
include_type = $bindable(['script', 'flow', 'app', 'folder'] as ObjectType[]),
|
||||
exclude_types_override = $bindable([] as ObjectType[]),
|
||||
isLegacyRepo = false,
|
||||
yamlText = $bindable(''),
|
||||
onSettingsChange = (settings: { yaml: string }) => {},
|
||||
excludes = $bindable([] as string[]),
|
||||
extraIncludes = $bindable([] as string[])
|
||||
} = $props()
|
||||
|
||||
// Component state
|
||||
let collapsed = $state(false)
|
||||
let editAsYaml = $state(false)
|
||||
let yamlError = $state('')
|
||||
let isPullMode = $state(false)
|
||||
|
||||
// Preview/Push state
|
||||
let previewResult = $state<PreviewResult | null>(null)
|
||||
let previewJobId = $state<string | null>(null)
|
||||
let previewJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
|
||||
let pushJobId = $state<string | null>(null)
|
||||
let pushJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
|
||||
let isPreviewLoading = $state(false)
|
||||
let isPushing = $state(false)
|
||||
let previewError = $state('')
|
||||
let previewSettingsSnapshot = $state<string | null>(null)
|
||||
|
||||
// Compute effective include types (include_type minus exclude_types_override for legacy repos only)
|
||||
const effectiveIncludeTypes = $derived(
|
||||
isLegacyRepo
|
||||
? include_type.filter((type) => !exclude_types_override.includes(type))
|
||||
: include_type
|
||||
)
|
||||
|
||||
// Compute type toggles from effective include types
|
||||
const typeToggles = $derived({
|
||||
scripts: effectiveIncludeTypes.includes('script'),
|
||||
flows: effectiveIncludeTypes.includes('flow'),
|
||||
apps: effectiveIncludeTypes.includes('app'),
|
||||
folders: effectiveIncludeTypes.includes('folder'),
|
||||
resourceTypes: effectiveIncludeTypes.includes('resourcetype'),
|
||||
resources: effectiveIncludeTypes.includes('resource'),
|
||||
variables: effectiveIncludeTypes.includes('variable'),
|
||||
secrets: effectiveIncludeTypes.includes('secret'),
|
||||
schedules: effectiveIncludeTypes.includes('schedule'),
|
||||
users: effectiveIncludeTypes.includes('user'),
|
||||
groups: effectiveIncludeTypes.includes('group'),
|
||||
triggers: effectiveIncludeTypes.includes('trigger'),
|
||||
settings: effectiveIncludeTypes.includes('settings'),
|
||||
key: effectiveIncludeTypes.includes('key')
|
||||
})
|
||||
|
||||
// Tab selection for filter kinds
|
||||
let filtersTab = $state<'includes' | 'excludes'>('includes')
|
||||
|
||||
function updateIncludeType(key: keyof GitSyncTypeMap, value: boolean) {
|
||||
const newTypes = new Set(include_type)
|
||||
const typeMap: Record<keyof GitSyncTypeMap, ObjectType> = {
|
||||
scripts: 'script',
|
||||
flows: 'flow',
|
||||
apps: 'app',
|
||||
folders: 'folder',
|
||||
resourceTypes: 'resourcetype',
|
||||
resources: 'resource',
|
||||
variables: 'variable',
|
||||
secrets: 'secret',
|
||||
schedules: 'schedule',
|
||||
users: 'user',
|
||||
groups: 'group',
|
||||
triggers: 'trigger',
|
||||
settings: 'settings',
|
||||
key: 'key'
|
||||
}
|
||||
|
||||
if (value) {
|
||||
newTypes.add(typeMap[key])
|
||||
} else {
|
||||
newTypes.delete(typeMap[key])
|
||||
if (key === 'variables') {
|
||||
newTypes.delete('secret')
|
||||
}
|
||||
}
|
||||
|
||||
include_type = Array.from(newTypes)
|
||||
}
|
||||
|
||||
function capitalize(str: string) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
|
||||
// Simple JSON-based UI state helper
|
||||
function getUIState() {
|
||||
return {
|
||||
include_path,
|
||||
exclude_path: excludes,
|
||||
extra_include_path: extraIncludes,
|
||||
include_type
|
||||
}
|
||||
}
|
||||
|
||||
// Apply settings from backend format (used by both local git repo and backend settings)
|
||||
function fromBackendFormat(settings: {
|
||||
include_path: string[]
|
||||
exclude_path: string[]
|
||||
extra_include_path: string[]
|
||||
include_type: ObjectType[]
|
||||
}) {
|
||||
include_path = settings.include_path || []
|
||||
excludes = settings.exclude_path || []
|
||||
extraIncludes = settings.extra_include_path || []
|
||||
include_type = settings.include_type || []
|
||||
}
|
||||
|
||||
// Simplified YAML parsing for manual editing
|
||||
function fromYaml(yamlStr: string) {
|
||||
yamlError = ''
|
||||
try {
|
||||
const parsed = yaml.load(yamlStr)
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new Error('Invalid YAML structure')
|
||||
}
|
||||
|
||||
const obj: any = parsed
|
||||
yamlText = yamlStr
|
||||
|
||||
// Extract includes - reset to default if not present
|
||||
if (obj.includes && Array.isArray(obj.includes)) {
|
||||
include_path = obj.includes.map((p: any) => {
|
||||
if (typeof p !== 'string') {
|
||||
throw new Error('includes must contain only strings')
|
||||
}
|
||||
// Handle quoted strings
|
||||
if (/^['"].*['"]$/.test(p)) {
|
||||
return p.slice(1, -1).replace(/''/g, "'")
|
||||
}
|
||||
return p
|
||||
})
|
||||
} else {
|
||||
// Reset to default if includes is not present
|
||||
include_path = ['f/**']
|
||||
}
|
||||
|
||||
// Build the type set based on the YAML flags
|
||||
const newTypes = new Set<ObjectType>()
|
||||
|
||||
// Always include core types (these are fundamental and not controlled by flags)
|
||||
newTypes.add('script')
|
||||
newTypes.add('flow')
|
||||
newTypes.add('app')
|
||||
newTypes.add('folder')
|
||||
|
||||
// Handle skip flags (if skipX is false or undefined, include the type)
|
||||
if (obj.skipResourceTypes !== true) newTypes.add('resourcetype')
|
||||
if (obj.skipResources !== true) newTypes.add('resource')
|
||||
if (obj.skipVariables !== true) newTypes.add('variable')
|
||||
if (obj.skipSecrets !== true) newTypes.add('secret')
|
||||
|
||||
// Handle include flags (if includeX is true, include the type)
|
||||
if (obj.includeSchedules === true) newTypes.add('schedule')
|
||||
if (obj.includeTriggers === true) newTypes.add('trigger')
|
||||
if (obj.includeUsers === true) newTypes.add('user')
|
||||
if (obj.includeGroups === true) newTypes.add('group')
|
||||
if (obj.includeSettings === true) newTypes.add('settings')
|
||||
if (obj.includeKey === true) newTypes.add('key')
|
||||
|
||||
// Apply business rule: secrets can only be included if variables are included
|
||||
// This matches the UI behavior where turning off variables also turns off secrets
|
||||
if (!newTypes.has('variable')) {
|
||||
newTypes.delete('secret')
|
||||
}
|
||||
|
||||
include_type = Array.from(newTypes)
|
||||
} catch (e) {
|
||||
yamlError = e.message || 'Invalid YAML'
|
||||
console.error('Error parsing YAML:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Simple YAML generation for manual editing mode
|
||||
function generateYamlFromUI() {
|
||||
try {
|
||||
const validIncludePath = include_path
|
||||
const validExcludePath = excludes
|
||||
const validExtraInclude = extraIncludes
|
||||
|
||||
// Basic YAML structure - let the CLI handle the proper normalization
|
||||
let config: any = {
|
||||
includes: validIncludePath,
|
||||
excludes: validExcludePath,
|
||||
extraIncludes: validExtraInclude,
|
||||
codebases: []
|
||||
}
|
||||
|
||||
// Let the CLI handle the optimization of skip/include flags
|
||||
// Just convert the UI state directly
|
||||
if (!include_type.includes('variable')) config.skipVariables = true
|
||||
if (!include_type.includes('resource')) config.skipResources = true
|
||||
if (!include_type.includes('secret')) config.skipSecrets = true
|
||||
if (!include_type.includes('resourcetype')) config.skipResourceTypes = true
|
||||
|
||||
if (include_type.includes('schedule')) config.includeSchedules = true
|
||||
if (include_type.includes('trigger')) config.includeTriggers = true
|
||||
if (include_type.includes('user')) config.includeUsers = true
|
||||
if (include_type.includes('group')) config.includeGroups = true
|
||||
if (include_type.includes('settings')) config.includeSettings = true
|
||||
if (include_type.includes('key')) config.includeKey = true
|
||||
|
||||
return yaml.dump(config, {
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
quotingType: '"',
|
||||
forceQuotes: false,
|
||||
noRefs: true
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('Failed to generate YAML:', e)
|
||||
yamlError = e.message || 'Failed to generate YAML'
|
||||
return `includes:
|
||||
- f/**
|
||||
excludes: []
|
||||
extraIncludes: []
|
||||
codebases: []`
|
||||
}
|
||||
}
|
||||
|
||||
function switchToYaml() {
|
||||
yamlText = generateYamlFromUI()
|
||||
yamlError = ''
|
||||
editAsYaml = true
|
||||
}
|
||||
|
||||
function switchToUI() {
|
||||
fromYaml(yamlText)
|
||||
if (!yamlError) {
|
||||
editAsYaml = false
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified preview function - always uses JSON approach
|
||||
async function previewFiltersToGitRepo() {
|
||||
isPreviewLoading = true
|
||||
previewError = ''
|
||||
previewResult = null
|
||||
previewJobId = null
|
||||
previewJobStatus = undefined
|
||||
|
||||
// Take a snapshot of current settings
|
||||
previewSettingsSnapshot = JSON.stringify({
|
||||
include_path,
|
||||
excludes,
|
||||
extraIncludes,
|
||||
include_type
|
||||
})
|
||||
|
||||
try {
|
||||
const workspace = $workspaceStore
|
||||
if (!workspace) return
|
||||
|
||||
// Always pass UI state as JSON - the backend now handles this uniformly
|
||||
const payloadObj = {
|
||||
workspace_id: workspace,
|
||||
repo_url_resource_path: git_repo_resource_path,
|
||||
only_wmill_yaml: true,
|
||||
dry_run: true,
|
||||
pull: isPullMode,
|
||||
settings_json: JSON.stringify(getUIState())
|
||||
}
|
||||
|
||||
const jobId = await JobService.runScriptByPath({
|
||||
workspace,
|
||||
path: hubPaths.gitInitRepo,
|
||||
requestBody: payloadObj,
|
||||
skipPreprocessor: true
|
||||
})
|
||||
|
||||
previewJobId = jobId
|
||||
previewJobStatus = 'running'
|
||||
let jobSuccess = false
|
||||
let result: PreviewResult = {}
|
||||
|
||||
await (
|
||||
await import('$lib/utils')
|
||||
).tryEvery({
|
||||
tryCode: async () => {
|
||||
const testResult = await JobService.getCompletedJob({ workspace, id: jobId })
|
||||
jobSuccess = !!testResult.success
|
||||
if (jobSuccess) {
|
||||
const jobResult = await JobService.getCompletedJobResult({ workspace, id: jobId })
|
||||
result = jobResult as PreviewResult
|
||||
}
|
||||
},
|
||||
timeoutCode: async () => {
|
||||
try {
|
||||
await JobService.cancelQueuedJob({
|
||||
workspace,
|
||||
id: jobId,
|
||||
requestBody: { reason: 'Preview job timed out after 5s' }
|
||||
})
|
||||
} catch (err) {}
|
||||
},
|
||||
interval: 500,
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
previewJobStatus = jobSuccess ? 'success' : 'failure'
|
||||
if (jobSuccess) {
|
||||
previewResult = result
|
||||
} else {
|
||||
previewError = 'Preview failed'
|
||||
}
|
||||
} catch (e) {
|
||||
previewJobStatus = 'failure'
|
||||
previewError = e?.message || 'Preview failed'
|
||||
previewResult = null
|
||||
} finally {
|
||||
isPreviewLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified push function - always uses JSON approach
|
||||
async function pushFiltersToGitRepo() {
|
||||
if (isPullMode) {
|
||||
// In pull mode, apply the local settings (from git repo) to UI
|
||||
if (previewResult?.local) {
|
||||
try {
|
||||
fromBackendFormat(previewResult.local)
|
||||
yamlText = generateYamlFromUI()
|
||||
onSettingsChange({ yaml: yamlText })
|
||||
sendUserToast('Changes applied - remember to save repository settings to persist changes')
|
||||
|
||||
// Clear the preview state after applying settings
|
||||
previewResult = null
|
||||
previewJobId = null
|
||||
previewJobStatus = undefined
|
||||
previewError = ''
|
||||
} catch (e) {
|
||||
previewError = 'Failed to apply pulled settings: ' + e.message
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Push mode - send current UI state as JSON
|
||||
isPushing = true
|
||||
pushJobId = null
|
||||
pushJobStatus = undefined
|
||||
|
||||
try {
|
||||
const workspace = $workspaceStore
|
||||
if (!workspace) return
|
||||
|
||||
const payloadObj = {
|
||||
workspace_id: workspace,
|
||||
repo_url_resource_path: git_repo_resource_path,
|
||||
dry_run: false,
|
||||
pull: isPullMode,
|
||||
only_wmill_yaml: true,
|
||||
settings_json: JSON.stringify(getUIState())
|
||||
}
|
||||
|
||||
const jobId = await JobService.runScriptByPath({
|
||||
workspace,
|
||||
path: hubPaths.gitInitRepo,
|
||||
requestBody: payloadObj,
|
||||
skipPreprocessor: true
|
||||
})
|
||||
|
||||
pushJobId = jobId
|
||||
pushJobStatus = 'running'
|
||||
let jobSuccess = false
|
||||
|
||||
await (
|
||||
await import('$lib/utils')
|
||||
).tryEvery({
|
||||
tryCode: async () => {
|
||||
const testResult = await JobService.getCompletedJob({ workspace, id: jobId })
|
||||
jobSuccess = !!testResult.success
|
||||
},
|
||||
timeoutCode: async () => {
|
||||
try {
|
||||
await JobService.cancelQueuedJob({
|
||||
workspace,
|
||||
id: jobId,
|
||||
requestBody: { reason: 'Push job timed out after 5s' }
|
||||
})
|
||||
} catch (err) {}
|
||||
},
|
||||
interval: 500,
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
pushJobStatus = jobSuccess ? 'success' : 'failure'
|
||||
if (jobSuccess) {
|
||||
// Reset preview state after successful push
|
||||
previewResult = null
|
||||
previewJobId = null
|
||||
previewJobStatus = undefined
|
||||
previewError = ''
|
||||
}
|
||||
} catch (e) {
|
||||
pushJobStatus = 'failure'
|
||||
} finally {
|
||||
isPushing = false
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified export function for backward compatibility
|
||||
export function toYaml() {
|
||||
return generateYamlFromUI()
|
||||
}
|
||||
|
||||
export function setSettings(settings: { yaml: string }) {
|
||||
yamlText = settings.yaml
|
||||
fromYaml(settings.yaml)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Reset preview state when switching modes
|
||||
if (isPullMode !== undefined) {
|
||||
previewResult = null
|
||||
previewJobId = null
|
||||
previewJobStatus = undefined
|
||||
pushJobId = null
|
||||
pushJobStatus = undefined
|
||||
isPreviewLoading = false
|
||||
isPushing = false
|
||||
previewError = ''
|
||||
}
|
||||
})
|
||||
|
||||
// Reset preview state when settings change (making preview stale)
|
||||
$effect(() => {
|
||||
// Track all the settings that affect the preview
|
||||
const currentSettings = JSON.stringify({
|
||||
include_path,
|
||||
excludes,
|
||||
extraIncludes,
|
||||
include_type
|
||||
})
|
||||
|
||||
// If we have an existing preview result and settings have changed from snapshot, clear it
|
||||
if (
|
||||
previewResult !== null &&
|
||||
previewSettingsSnapshot !== null &&
|
||||
currentSettings !== previewSettingsSnapshot
|
||||
) {
|
||||
previewResult = null
|
||||
previewJobId = null
|
||||
previewJobStatus = undefined
|
||||
previewError = ''
|
||||
previewSettingsSnapshot = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg shadow-sm border p-0 w-full">
|
||||
<!-- Card Header -->
|
||||
<div class="flex items-center justify-between min-h-10 px-4 py-1 border-b">
|
||||
<div class="flex items-center gap-2">
|
||||
<Filter size={18} class="text-primary" />
|
||||
<span class="font-semibold text-sm">Git Sync filter settings</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !collapsed}
|
||||
<button
|
||||
class="text-xs px-2 py-1 rounded border border-gray-300 bg-surface-primary hover:bg-surface-secondary"
|
||||
onclick={editAsYaml ? switchToUI : switchToYaml}
|
||||
>
|
||||
{editAsYaml ? 'Edit in UI' : 'Edit as YAML'}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="text-gray-500 hover:text-primary focus:outline-none"
|
||||
onclick={() => (collapsed = !collapsed)}
|
||||
aria-label="Toggle collapse"
|
||||
>
|
||||
{#if collapsed}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 15l7-7 7 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if !collapsed}
|
||||
{#if editAsYaml}
|
||||
<div class="px-4 py-4">
|
||||
<textarea
|
||||
class="w-full h-64 font-mono text-xs border rounded p-2 bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
spellcheck="false"
|
||||
bind:value={yamlText}
|
||||
></textarea>
|
||||
{#if yamlError}
|
||||
<div class="text-xs text-red-600 mt-2">{yamlError}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-4 py-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 md:gap-32">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Tabs bind:selected={filtersTab}>
|
||||
<Tab value="includes">Includes</Tab>
|
||||
<Tab value="excludes">Excludes</Tab>
|
||||
</Tabs>
|
||||
|
||||
{#if filtersTab === 'includes'}
|
||||
<FilterList
|
||||
title="Include path filters"
|
||||
bind:items={include_path}
|
||||
placeholder="Add filter (e.g. f/**)"
|
||||
>
|
||||
{#snippet tooltip()}
|
||||
<Tooltip>
|
||||
Only scripts, flows and apps with their path matching one of those filters will
|
||||
be synced to the Git repositories below. The filters allow '*' and '**'
|
||||
characters, with '*' matching any character allowed in paths until the next
|
||||
slash (/) and '**' matching anything including slashes. By default everything in
|
||||
folders will be synced.
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
</FilterList>
|
||||
{:else if filtersTab === 'excludes'}
|
||||
<FilterList
|
||||
title="Exclude path filters"
|
||||
bind:items={excludes}
|
||||
placeholder="Add filter (e.g. f/**)"
|
||||
>
|
||||
{#snippet tooltip()}
|
||||
<Tooltip>
|
||||
After the include / extra include checks, if a file matches any of these
|
||||
patterns it will be skipped.
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
</FilterList>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Type Filters Section (Right) -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<h4 class="font-semibold text-sm">Type filters</h4>
|
||||
<Tooltip>
|
||||
On top of the filter path above, you can include only certain type of object to be
|
||||
synced with the Git repository. By default everything is synced.
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
checked={typeToggles.scripts}
|
||||
on:change={(e) => updateIncludeType('scripts', e.detail)}
|
||||
options={{ right: capitalize('scripts') }}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
checked={typeToggles.flows}
|
||||
on:change={(e) => updateIncludeType('flows', e.detail)}
|
||||
options={{ right: capitalize('flows') }}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
checked={typeToggles.apps}
|
||||
on:change={(e) => updateIncludeType('apps', e.detail)}
|
||||
options={{ right: capitalize('apps') }}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
checked={typeToggles.folders}
|
||||
on:change={(e) => updateIncludeType('folders', e.detail)}
|
||||
options={{ right: capitalize('folders') }}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
checked={typeToggles.resourceTypes}
|
||||
on:change={(e) => updateIncludeType('resourceTypes', e.detail)}
|
||||
options={{ right: capitalize('resourceTypes') }}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
checked={typeToggles.resources}
|
||||
on:change={(e) => updateIncludeType('resources', e.detail)}
|
||||
options={{ right: capitalize('resources') }}
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2 flex items-center gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
checked={typeToggles.variables}
|
||||
on:change={(e) => updateIncludeType('variables', e.detail)}
|
||||
options={{ right: 'Variables' }}
|
||||
/>
|
||||
<span class="text-gray-400">-</span>
|
||||
<Toggle
|
||||
size="xs"
|
||||
disabled={!typeToggles.variables}
|
||||
checked={typeToggles.secrets}
|
||||
on:change={(e) => updateIncludeType('secrets', e.detail)}
|
||||
options={{ left: 'Include secrets' }}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
checked={typeToggles.schedules}
|
||||
on:change={(e) => updateIncludeType('schedules', e.detail)}
|
||||
options={{ right: capitalize('schedules') }}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
checked={typeToggles.users}
|
||||
on:change={(e) => updateIncludeType('users', e.detail)}
|
||||
options={{ right: capitalize('users') }}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
checked={typeToggles.groups}
|
||||
on:change={(e) => updateIncludeType('groups', e.detail)}
|
||||
options={{ right: capitalize('groups') }}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
checked={typeToggles.triggers}
|
||||
on:change={(e) => updateIncludeType('triggers', e.detail)}
|
||||
options={{ right: capitalize('triggers') }}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
checked={typeToggles.settings}
|
||||
on:change={(e) => updateIncludeType('settings', e.detail)}
|
||||
options={{ right: 'Workspace settings' }}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
checked={typeToggles.key}
|
||||
on:change={(e) => updateIncludeType('key', e.detail)}
|
||||
options={{ right: 'Encryption key' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex flex-col gap-2 p-2">
|
||||
<div class="flex flex-col gap-2 mb-2">
|
||||
<Toggle
|
||||
size="sm"
|
||||
bind:checked={isPullMode}
|
||||
options={{
|
||||
left: 'Push',
|
||||
right: 'Pull'
|
||||
}}
|
||||
/>
|
||||
<span class="text-xs text-tertiary">
|
||||
{isPullMode ? 'Pull settings from Git repository' : 'Push settings to Git repository'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<Button
|
||||
size="sm"
|
||||
on:click={previewFiltersToGitRepo}
|
||||
disabled={isPreviewLoading || isPushing}
|
||||
startIcon={{
|
||||
icon: isPreviewLoading ? Loader2 : Eye,
|
||||
classes: isPreviewLoading ? 'animate-spin' : ''
|
||||
}}
|
||||
>
|
||||
{isPreviewLoading ? 'Previewing...' : 'Preview'}
|
||||
</Button>
|
||||
{#if previewResult?.hasChanges && (previewResult?.isInitialSetup || (previewResult?.diff && Object.keys(previewResult.diff).length > 0))}
|
||||
<Button
|
||||
size="sm"
|
||||
on:click={pushFiltersToGitRepo}
|
||||
disabled={isPushing || isPreviewLoading}
|
||||
color={isPullMode ? 'dark' : 'red'}
|
||||
startIcon={{
|
||||
icon: isPushing ? Loader2 : isPullMode ? Check : Save,
|
||||
classes: isPushing ? 'animate-spin' : ''
|
||||
}}
|
||||
>
|
||||
{isPushing
|
||||
? isPullMode
|
||||
? 'Applying...'
|
||||
: 'Pushing...'
|
||||
: isPullMode
|
||||
? 'Apply'
|
||||
: 'Push Settings to Git'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if previewError}
|
||||
<div class="text-xs text-red-600 mt-2">{previewError}</div>
|
||||
{/if}
|
||||
{#if previewJobId}
|
||||
<div class="flex items-center gap-2 text-xs text-tertiary mt-1">
|
||||
{#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}
|
||||
{#if previewResult}
|
||||
<div
|
||||
class="border rounded p-2 text-xs max-h-40 overflow-y-auto bg-surface-secondary mt-2"
|
||||
>
|
||||
<div class="font-semibold text-[11px] mb-1 text-tertiary">Preview of changes:</div>
|
||||
{#if previewResult.isInitialSetup}
|
||||
<div class="mt-2 text-green-600">
|
||||
{previewResult.message || 'wmill.yaml will be created with repository settings'}
|
||||
</div>
|
||||
{:else if previewResult.hasChanges && previewResult.diff && Object.keys(previewResult.diff).length > 0}
|
||||
<div class="mt-2 space-y-1">
|
||||
{#each Object.entries(previewResult.diff) as [field, change]}
|
||||
<div class="flex items-start gap-2 text-2xs">
|
||||
<span class="font-mono text-tertiary min-w-0 flex-shrink-0">{field}:</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if Array.isArray(change.from) || Array.isArray(change.to)}
|
||||
<div class="space-y-0.5">
|
||||
<div class="text-red-600">- {JSON.stringify(change.from)}</div>
|
||||
<div class="text-green-600">+ {JSON.stringify(change.to)}</div>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-red-600">{JSON.stringify(change.from)}</span>
|
||||
<span class="text-tertiary"> → </span>
|
||||
<span class="text-green-600">{JSON.stringify(change.to)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-2 text-tertiary">No changes found! The file is up to date.</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if pushJobId}
|
||||
<div class="flex items-center gap-2 text-xs text-tertiary mt-1">
|
||||
{#if pushJobStatus === 'running'}
|
||||
<Loader2 class="animate-spin" size={14} />
|
||||
{:else if pushJobStatus === 'success'}
|
||||
<CheckCircle2 size={14} class="text-green-600" />
|
||||
{:else if pushJobStatus === 'failure'}
|
||||
<XCircle size={14} class="text-red-700" />
|
||||
{/if}
|
||||
Push job:
|
||||
<a
|
||||
target="_blank"
|
||||
class="underline"
|
||||
href={`/run/${pushJobId}?workspace=${$workspaceStore}`}>{pushJobId}</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -83,6 +83,7 @@ export function appToHubUrl(staticApp: any, hubBaseUrl: string): URL {
|
||||
type HubPaths = {
|
||||
gitSync: string
|
||||
gitSyncTest: string
|
||||
gitInitRepo: string
|
||||
slackErrorHandler: string
|
||||
slackRecoveryHandler: string
|
||||
slackSuccessHandler: string
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"gitSyncTest_1": "hub/11499/git-repo-test-read-write-windmill",
|
||||
"gitSyncTest_2": "hub/11667/git-repo-test-read-write-windmill",
|
||||
"gitSyncTest": "hub/11669/git-repo-test-read-write-windmill",
|
||||
"gitInitRepo": "hub/19740/git-sync%3A-init-repository-windmill",
|
||||
"slackErrorHandler": "hub/19741/workspace-or-schedule-error-handler-slack",
|
||||
"slackErrorHandler_0": "hub/9079/workspace-or-schedule-error-handler-slack",
|
||||
"slackErrorHandler_1": "hub/9206/workspace-or-schedule-error-handler-slack",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user