mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 08:04:25 +00:00
* feat: add cosmetic dev/staging label for dev workspaces Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: prefill dev fork name and use a link to switch its label Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style: reword the dev/staging label link copy Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style: preview the dev/staging label as a badge in the switch link Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: show the dev/staging badge in the session diff drawer header Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
337 lines
11 KiB
Svelte
337 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { workspaceStore, userWorkspaces } from '$lib/stores'
|
|
import { WorkspaceService, ScriptService } from '$lib/gen'
|
|
import type { WorkspaceComparison } from '$lib/gen'
|
|
import { Button } from './common'
|
|
import { AlertTriangle, GitFork, CircleCheck, CircleX, Loader2 } from 'lucide-svelte'
|
|
import { goto } from '$app/navigation'
|
|
import { onMount, untrack } from 'svelte'
|
|
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
|
|
import { devLabelWord } from '$lib/utils/devWorkspaceLabel'
|
|
|
|
let loading = $state(false)
|
|
let comparison: WorkspaceComparison | undefined = $state(undefined)
|
|
let error: string | undefined = $state(undefined)
|
|
|
|
let currentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
|
|
let parentWorkspaceId = $derived(currentWorkspaceData?.parent_workspace_id)
|
|
let parentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === parentWorkspaceId))
|
|
// Detect fork/dev workspaces by their parent link, not the `wm-fork-` id prefix (dev
|
|
// workspaces have an ordinary, prefix-less id). Keying on the parent (rather than the
|
|
// prefix) also avoids a parentless "Fork of ()" banner when the linkage is dropped.
|
|
let isFork = $derived(parentWorkspaceId != null)
|
|
let isDevWorkspace = $derived(currentWorkspaceData?.is_dev_workspace ?? false)
|
|
|
|
// Drafts in this fork. When the fork is otherwise in sync with its parent, a
|
|
// user with only pending drafts should still get the draft CTA (mirrors the
|
|
// non-fork WorkspaceDraftsBanner). Pass undefined when not a fork so it doesn't
|
|
// fetch.
|
|
const drafts = useWorkspaceDrafts(() => (isFork ? ($workspaceStore ?? undefined) : undefined))
|
|
const draftCount = $derived(drafts.count)
|
|
|
|
// Fork is fully in sync with its parent (comparison ran, no ahead/behind diffs).
|
|
// Typed helper avoids the $state `never`-inference quirk on `comparison` in $derived.
|
|
function isUpToDate(c: WorkspaceComparison | undefined): boolean {
|
|
return !!c && !c.skipped_comparison && c.summary.total_diffs === 0
|
|
}
|
|
let upToDate = $derived(isUpToDate(comparison))
|
|
// Up to date with the parent but local drafts are pending — show the draft
|
|
// state (same text + CTA as the draft banner) instead of "Everything is up to date".
|
|
let showDraftsOnly = $derived(upToDate && draftCount > 0)
|
|
|
|
$effect(() => {
|
|
;[$workspaceStore, parentWorkspaceId]
|
|
untrack(() => {
|
|
if (isFork && $workspaceStore) {
|
|
checkForChanges()
|
|
} else {
|
|
comparison = undefined
|
|
}
|
|
})
|
|
})
|
|
|
|
onMount(() => {
|
|
if (isFork && $workspaceStore) {
|
|
checkForChanges()
|
|
} else {
|
|
comparison = undefined
|
|
}
|
|
})
|
|
|
|
async function checkForChanges() {
|
|
if (!$workspaceStore || !parentWorkspaceId) {
|
|
return
|
|
}
|
|
|
|
loading = true
|
|
error = undefined
|
|
|
|
try {
|
|
// Compare with parent workspace
|
|
const result = await WorkspaceService.compareWorkspaces({
|
|
workspace: parentWorkspaceId,
|
|
targetWorkspaceId: $workspaceStore
|
|
})
|
|
|
|
comparison = result
|
|
} catch (e) {
|
|
console.error('Failed to compare workspaces:', e)
|
|
error = `Failed to check for changes: ${e}`
|
|
// Still show banner if there's an error, but with error message
|
|
} finally {
|
|
loading = false
|
|
}
|
|
}
|
|
|
|
function openComparisonDrawer() {
|
|
if (parentWorkspaceId && $workspaceStore) {
|
|
goto('/forks/compare?workspace_id=' + encodeURIComponent($workspaceStore), {
|
|
replaceState: true
|
|
})
|
|
}
|
|
}
|
|
|
|
function openDraftCompare() {
|
|
if ($workspaceStore) {
|
|
goto('/forks/compare?workspace_id=' + encodeURIComponent($workspaceStore) + '&mode=draft', {
|
|
replaceState: true
|
|
})
|
|
}
|
|
}
|
|
|
|
let ciTestPassing = $state(0)
|
|
let ciTestFailing = $state(0)
|
|
let ciTestRunning = $state(0)
|
|
let ciTestTotal = $state(0)
|
|
|
|
async function fetchCiTestSummary() {
|
|
if (!$workspaceStore || !comparison?.diffs) return
|
|
const items = comparison.diffs
|
|
.filter((d) => d.kind === 'script' || d.kind === 'flow' || d.kind === 'resource')
|
|
.map((d) => ({ path: d.path, kind: d.kind as 'script' | 'flow' | 'resource' }))
|
|
if (items.length === 0) return
|
|
try {
|
|
const batch = await ScriptService.getCiTestResultsBatch({
|
|
workspace: $workspaceStore,
|
|
requestBody: { items }
|
|
})
|
|
let passing = 0
|
|
let failing = 0
|
|
let running = 0
|
|
let total = 0
|
|
for (const results of Object.values(batch)) {
|
|
for (const r of results) {
|
|
total++
|
|
if (r.status === 'success') passing++
|
|
else if (r.status === 'failure' || r.status === 'canceled') failing++
|
|
else if (r.status === 'running' || (r.job_id && !r.status)) running++
|
|
}
|
|
}
|
|
ciTestPassing = passing
|
|
ciTestFailing = failing
|
|
ciTestRunning = running
|
|
ciTestTotal = total
|
|
} catch (e) {
|
|
console.error('Failed to fetch CI test summary:', e)
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (comparison && comparison.summary.total_diffs > 0) {
|
|
fetchCiTestSummary()
|
|
}
|
|
})
|
|
|
|
// Poll while any CI test is still running
|
|
$effect(() => {
|
|
if (ciTestRunning <= 0) return
|
|
const interval = setInterval(fetchCiTestSummary, 3000)
|
|
return () => clearInterval(interval)
|
|
})
|
|
|
|
function forkAheadBehindMessage(changesAhead: number, changesBehind: number) {
|
|
let msg: string[] = []
|
|
if (changesAhead > 0 || changesBehind > 0) {
|
|
msg.push('This fork is ')
|
|
if (changesAhead > 0)
|
|
msg.push(`${changesAhead} change${changesAhead > 1 ? 's' : ''} ahead of `)
|
|
if (changesAhead > 0 && changesBehind > 0) msg.push('and ')
|
|
if (changesBehind > 0)
|
|
msg.push(`${changesBehind} change${changesBehind > 1 ? 's' : ''} behind `)
|
|
}
|
|
return msg.join('')
|
|
}
|
|
</script>
|
|
|
|
{#if isFork}
|
|
<div class="w-full bg-blue-50 dark:bg-blue-900 text-xs rounded-b-md max-w-7xl mx-auto">
|
|
<div class="px-4 py-2">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<GitFork class="w-4 h-4 text-accent" />
|
|
<div class="text-sm">
|
|
<span class="font-medium text-blue-900 dark:text-blue-100">
|
|
{isDevWorkspace
|
|
? `${devLabelWord(currentWorkspaceData?.dev_workspace_label)} workspace of`
|
|
: 'Fork of'}
|
|
<b>{parentWorkspaceData?.name}</b> ({parentWorkspaceId})
|
|
</span>
|
|
</div>
|
|
|
|
{#if loading}
|
|
<span class="text-xs text-blue-600 dark:text-blue-400"> Checking for changes... </span>
|
|
{:else if error}
|
|
<span class="text-xs text-red-600 dark:text-red-400">
|
|
{error}
|
|
</span>
|
|
{:else if comparison}
|
|
<div class="flex items-center gap-4 text-xs">
|
|
{#if comparison.summary.total_diffs > 0}
|
|
<span class="text-blue-700 dark:text-blue-100">
|
|
{forkAheadBehindMessage(
|
|
comparison.summary.total_ahead,
|
|
comparison.summary.total_behind
|
|
)}
|
|
<span class="font-semibold underline">{parentWorkspaceId}</span> over {comparison
|
|
.summary.total_diffs} items:
|
|
</span>
|
|
<div class="flex items-center gap-2">
|
|
{#if comparison.summary.scripts_changed > 0}
|
|
<span class="text-blue-700 dark:text-blue-100">
|
|
{comparison.summary.scripts_changed} script{comparison.summary
|
|
.scripts_changed !== 1
|
|
? 's'
|
|
: ''}
|
|
</span>
|
|
{/if}
|
|
{#if comparison.summary.flows_changed > 0}
|
|
<span class="text-blue-700 dark:text-blue-100">
|
|
{comparison.summary.flows_changed} flow{comparison.summary.flows_changed !== 1
|
|
? 's'
|
|
: ''}
|
|
</span>
|
|
{/if}
|
|
{#if comparison.summary.apps_changed > 0}
|
|
<span class="text-blue-700 dark:text-blue-100">
|
|
{comparison.summary.apps_changed} app{comparison.summary.apps_changed !== 1
|
|
? 's'
|
|
: ''}
|
|
</span>
|
|
{/if}
|
|
{#if comparison.summary.resources_changed > 0}
|
|
<span class="text-blue-700 dark:text-blue-100">
|
|
{comparison.summary.resources_changed} resource{comparison.summary
|
|
.resources_changed !== 1
|
|
? 's'
|
|
: ''}
|
|
</span>
|
|
{/if}
|
|
{#if comparison.summary.variables_changed > 0}
|
|
<span class="text-blue-700 dark:text-blue-100">
|
|
{comparison.summary.variables_changed} variable{comparison.summary
|
|
.variables_changed !== 1
|
|
? 's'
|
|
: ''}
|
|
</span>
|
|
{/if}
|
|
{#if comparison.summary.resource_types_changed > 0}
|
|
<span class="text-blue-700 dark:text-blue-100">
|
|
{comparison.summary.resource_types_changed} resource type{comparison.summary
|
|
.resource_types_changed !== 1
|
|
? 's'
|
|
: ''}
|
|
</span>
|
|
{/if}
|
|
{#if comparison.summary.folders_changed > 0}
|
|
<span class="text-blue-700 dark:text-blue-100">
|
|
{comparison.summary.folders_changed} folder{comparison.summary
|
|
.folders_changed !== 1
|
|
? 's'
|
|
: ''}
|
|
</span>
|
|
{/if}
|
|
{#if comparison.summary.schedules_changed > 0}
|
|
<span class="text-blue-700 dark:text-blue-100">
|
|
{comparison.summary.schedules_changed} schedule{comparison.summary
|
|
.schedules_changed !== 1
|
|
? 's'
|
|
: ''}
|
|
</span>
|
|
{/if}
|
|
{#if comparison.summary.triggers_changed > 0}
|
|
<span class="text-blue-700 dark:text-blue-100">
|
|
{comparison.summary.triggers_changed} trigger{comparison.summary
|
|
.triggers_changed !== 1
|
|
? 's'
|
|
: ''}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if ciTestTotal > 0}
|
|
-
|
|
{#if ciTestFailing > 0}
|
|
<div class="flex items-center gap-1 text-red-600 dark:text-red-400">
|
|
<CircleX class="w-3 h-3" />
|
|
<span>CI: {ciTestFailing} failing</span>
|
|
</div>
|
|
{:else if ciTestRunning > 0}
|
|
<div class="flex items-center gap-1 text-yellow-600 dark:text-yellow-400">
|
|
<Loader2 class="w-3 h-3 animate-spin" />
|
|
<span>CI: {ciTestRunning} running</span>
|
|
</div>
|
|
{:else}
|
|
<div class="flex items-center gap-1 text-green-600 dark:text-green-400">
|
|
<CircleCheck class="w-3 h-3" />
|
|
<span>CI: {ciTestPassing} passing</span>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if comparison.summary.conflicts > 0}
|
|
-
|
|
<div class="flex items-center gap-1 text-orange-600 dark:text-orange-400">
|
|
<AlertTriangle class="w-3 h-3" />
|
|
<span
|
|
>{comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1
|
|
? 's'
|
|
: ''}</span
|
|
>
|
|
</div>
|
|
{/if}
|
|
{:else if comparison.skipped_comparison}
|
|
<span class="text-blue-600 dark:text-blue-200">
|
|
This fork was created before the addition of certain windmill features, and
|
|
therefore the changes with its parent workspace cannot be displayed.</span
|
|
>
|
|
{:else if showDraftsOnly}
|
|
<span class="text-blue-700 dark:text-blue-100">
|
|
This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''}
|
|
</span>
|
|
{:else}
|
|
<span class="text-blue-600 dark:text-blue-200"> Everything is up to date </span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<Button
|
|
variant="default"
|
|
unifiedSize="sm"
|
|
onclick={showDraftsOnly ? openDraftCompare : openComparisonDrawer}
|
|
>
|
|
{#if showDraftsOnly}
|
|
Review & deploy drafts
|
|
{:else if (comparison?.summary.total_ahead ?? 0) > 0}
|
|
Review & Deploy Changes
|
|
{:else}
|
|
Review & Update fork
|
|
{/if}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|