mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-28 16:02:47 +00:00
feat(deploy-to-hub): rehydrate project state, wire review flow, bundle trigger resources
- Rehydrate the publish panel from the Hub by source_id on load (phase, slug,
metadata, items, hub ids, recordings) so refresh no longer loses the draft.
- Map Hub project status to the draft/under_review/live phase; submitForReview
now persists to the Hub instead of a local stub; drop the unused v{n} version
display (status is the source of truth).
- Send source_path (original workspace path) per item for recording round-trip.
- Detect resources referenced by triggers, add them to the bundle closure
(extraResourcePaths) so they appear in dependencies, get stubbed/relocated,
and rewrite the trigger config path via the full bundle pathMap (no leaked
private path); show trigger usages in the dependency drawer.
- Review fixes: Array.isArray guards on trigger topic/subject lists; snapshot
relevantTriggers in deployAll to avoid a mid-deploy folder-switch race;
index-key the usage list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5a56424184
commit
1a32b6e5ad
@@ -57,7 +57,8 @@
|
||||
Play,
|
||||
RotateCcw,
|
||||
TriangleAlert,
|
||||
X
|
||||
X,
|
||||
Zap
|
||||
} from 'lucide-svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
@@ -121,6 +122,21 @@
|
||||
gcp: 'Re-link GCP Pub/Sub subscription after import.',
|
||||
azure: 'Re-link Azure Event Grid subscription after import.'
|
||||
}
|
||||
// Per-kind config field holding the resource path a trigger depends on.
|
||||
const TRIGGER_RESOURCE_FIELD: Partial<Record<TriggerKindLabel, string>> = {
|
||||
kafka: 'kafka_resource_path',
|
||||
nats: 'nats_resource_path',
|
||||
sqs: 'aws_resource_path',
|
||||
mqtt: 'mqtt_resource_path',
|
||||
gcp: 'gcp_resource_path',
|
||||
azure: 'azure_resource_path',
|
||||
postgres: 'postgres_resource_path'
|
||||
}
|
||||
function triggerResourcePath(t: WorkspaceTrigger): string | undefined {
|
||||
const field = TRIGGER_RESOURCE_FIELD[t.kind]
|
||||
const v = field ? (t.config as any)?.[field] : undefined
|
||||
return typeof v === 'string' && v !== '' ? v : undefined
|
||||
}
|
||||
|
||||
let phase = $state<Phase>('predeploy')
|
||||
let workspaceItems = $state<DeployItem[]>([])
|
||||
@@ -505,11 +521,11 @@
|
||||
case 'kafka':
|
||||
push('Resource', c.kafka_resource_path)
|
||||
push('Group', c.group_id)
|
||||
push('Topics', (c.topics ?? []).join(', '))
|
||||
push('Topics', (Array.isArray(c.topics) ? c.topics : []).join(', '))
|
||||
break
|
||||
case 'nats':
|
||||
push('Resource', c.nats_resource_path)
|
||||
push('Subjects', (c.subjects ?? []).join(', '))
|
||||
push('Subjects', (Array.isArray(c.subjects) ? c.subjects : []).join(', '))
|
||||
push('Jetstream', c.use_jetstream)
|
||||
break
|
||||
case 'sqs':
|
||||
@@ -518,7 +534,12 @@
|
||||
break
|
||||
case 'mqtt':
|
||||
push('Resource', c.mqtt_resource_path)
|
||||
push('Topics', (c.subscribe_topics ?? []).map((x: any) => x?.topic ?? x).join(', '))
|
||||
push(
|
||||
'Topics',
|
||||
(Array.isArray(c.subscribe_topics) ? c.subscribe_topics : [])
|
||||
.map((x: any) => x?.topic ?? x)
|
||||
.join(', ')
|
||||
)
|
||||
break
|
||||
case 'gcp':
|
||||
push('Resource', c.gcp_resource_path)
|
||||
@@ -535,7 +556,7 @@
|
||||
push('Publication', c.publication_name)
|
||||
break
|
||||
case 'email':
|
||||
push('Local part', c.local_part)
|
||||
push('Email prefix', c.local_part ? `${c.local_part}@…` : undefined)
|
||||
break
|
||||
}
|
||||
return out
|
||||
@@ -824,15 +845,20 @@
|
||||
return out
|
||||
}
|
||||
|
||||
async function pushTriggers(workspace: string, slug: string): Promise<void> {
|
||||
if (relevantTriggers.length === 0) return
|
||||
async function pushTriggers(
|
||||
workspace: string,
|
||||
slug: string,
|
||||
resourcePathMap: Map<string, string>,
|
||||
relevant: WorkspaceTrigger[]
|
||||
): Promise<void> {
|
||||
if (relevant.length === 0) return
|
||||
const pathMap = buildPathMap(
|
||||
relevantTriggers.map((t) => t.path),
|
||||
relevant.map((t) => t.path),
|
||||
slug
|
||||
)
|
||||
const triggers: Array<Record<string, unknown>> = []
|
||||
const skipped: string[] = []
|
||||
for (const t of relevantTriggers) {
|
||||
for (const t of relevant) {
|
||||
const itemKind: ItemKind = t.is_flow ? 'flow' : 'script'
|
||||
const runnableKey = `${itemKind}:${t.script_path}`
|
||||
const hubId = hubItemIds[runnableKey]
|
||||
@@ -840,12 +866,17 @@
|
||||
skipped.push(t.path)
|
||||
continue
|
||||
}
|
||||
const config = stripTriggerConfig(t.config)
|
||||
const field = TRIGGER_RESOURCE_FIELD[t.kind]
|
||||
if (field && typeof config[field] === 'string') {
|
||||
config[field] = resourcePathMap.get(config[field] as string) ?? config[field]
|
||||
}
|
||||
triggers.push({
|
||||
path: pathMap.get(t.path) ?? t.path,
|
||||
kind: t.kind,
|
||||
summary: t.summary ?? null,
|
||||
description: (t.config as any)?.description ?? null,
|
||||
config: stripTriggerConfig(t.config),
|
||||
config,
|
||||
script_ask_id: t.is_flow ? null : hubId,
|
||||
flow_id: t.is_flow ? hubId : null
|
||||
})
|
||||
@@ -903,6 +934,7 @@
|
||||
type DependencyUsage =
|
||||
| { role: 'input'; label: string; kind: ItemKind; itemPath: string }
|
||||
| { role: 'hardcoded'; label: string; kind: ItemKind; path: string; itemPath: string }
|
||||
| { role: 'trigger'; label: string; triggerKind: TriggerKindLabel; path: string }
|
||||
interface DependencyType {
|
||||
resource_type: string
|
||||
hasHardcoded: boolean
|
||||
@@ -947,6 +979,19 @@
|
||||
ensure(t).usages.push({ role: 'input', label, kind: it.kind, itemPath: it.path })
|
||||
}
|
||||
}
|
||||
// Resources referenced only by a trigger (no item uses them in code).
|
||||
const stubByOriginal = new Map(b.resourceStubs.map((s) => [s.originalPath, s]))
|
||||
for (const t of relevantTriggers) {
|
||||
const rp = triggerResourcePath(t)
|
||||
const stub = rp ? stubByOriginal.get(rp) : undefined
|
||||
if (!stub || HIDDEN_RESOURCE_TYPES.has(stub.resource_type)) continue
|
||||
ensure(stub.resource_type).usages.push({
|
||||
role: 'trigger',
|
||||
label: t.summary?.trim() || t.path,
|
||||
triggerKind: t.kind,
|
||||
path: stub.originalPath
|
||||
})
|
||||
}
|
||||
return [...byType.values()].sort((a, b) => a.resource_type.localeCompare(b.resource_type))
|
||||
})
|
||||
|
||||
@@ -963,7 +1008,10 @@
|
||||
const seed: ItemRef[] = selectedItems
|
||||
.filter((i) => i.kind !== 'resource')
|
||||
.map((i) => ({ kind: i.kind as ItemRef['kind'], path: i.path }))
|
||||
buildProjectBundle(seed, slug, buildBundleDeps(workspace))
|
||||
const triggerResources = relevantTriggers
|
||||
.map(triggerResourcePath)
|
||||
.filter((p): p is string => !!p)
|
||||
buildProjectBundle(seed, slug, buildBundleDeps(workspace), triggerResources)
|
||||
.then((b) => {
|
||||
if (!cancelled) bundlePreview = b
|
||||
})
|
||||
@@ -1014,13 +1062,25 @@
|
||||
// Snapshot the selection up-front so a folder switch mid-deploy can't
|
||||
// rewrite draftItems on success.
|
||||
const itemsSnapshot = selectedItems.slice()
|
||||
const triggersSnapshot = relevantTriggers.slice()
|
||||
deploying = true
|
||||
let failures = 0
|
||||
try {
|
||||
const seed: ItemRef[] = itemsSnapshot
|
||||
.filter((i) => i.kind !== 'resource')
|
||||
.map((i) => ({ kind: i.kind as ItemRef['kind'], path: i.path }))
|
||||
const bundle = await buildProjectBundle(seed, slug, buildBundleDeps(workspace))
|
||||
const triggerResources = triggersSnapshot
|
||||
.map(triggerResourcePath)
|
||||
.filter((p): p is string => !!p)
|
||||
const bundle = await buildProjectBundle(
|
||||
seed,
|
||||
slug,
|
||||
buildBundleDeps(workspace),
|
||||
triggerResources
|
||||
)
|
||||
// Full path map (incl. unresolved) so a trigger's resource path is always
|
||||
// relocated — never leaks the publisher's original private path to the Hub.
|
||||
const resourcePathMap = bundle.pathMap
|
||||
|
||||
if (bundle.unresolved.length > 0) {
|
||||
sendUserToast(
|
||||
@@ -1094,7 +1154,7 @@
|
||||
}
|
||||
}
|
||||
try {
|
||||
await pushTriggers(workspace, slug)
|
||||
await pushTriggers(workspace, slug, resourcePathMap, triggersSnapshot)
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Trigger sync failed: ${e?.message ?? e}`, true)
|
||||
failures++
|
||||
@@ -2022,59 +2082,71 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each r.usages as u (u.role + ':' + u.label + ':' + (u.role === 'hardcoded' ? u.path : ''))}
|
||||
{@const itemUrl = openItemUrl(u.kind, u.itemPath)}
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if u.kind === 'script'}
|
||||
<Code2 size={14} class="shrink-0 text-hint" />
|
||||
{:else if u.kind === 'flow'}
|
||||
<BarsStaggered size={14} class="shrink-0 text-hint" />
|
||||
{:else}
|
||||
<LayoutDashboard size={14} class="shrink-0 text-hint" />
|
||||
{/if}
|
||||
{#each r.usages as u, ui (ui)}
|
||||
{#if u.role === 'trigger'}
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<Zap size={14} class="shrink-0 text-hint" />
|
||||
<span class="break-all font-mono text-primary">{u.label}</span>
|
||||
<span
|
||||
class="ml-auto inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide {u.role ===
|
||||
'hardcoded'
|
||||
? 'bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-200'
|
||||
: 'bg-surface text-secondary'}"
|
||||
class="ml-auto inline-flex shrink-0 items-center gap-1 rounded bg-surface px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-secondary"
|
||||
>
|
||||
{u.role === 'hardcoded' ? 'hardcoded path' : 'input'}
|
||||
{#if u.role === 'hardcoded'}
|
||||
<Popover notClickable placement="top">
|
||||
<Info size={11} class="text-amber-600 dark:text-amber-400" />
|
||||
{#snippet text()}
|
||||
<div
|
||||
class="flex w-80 max-w-[90vw] flex-col gap-2 text-left text-[11px] normal-case"
|
||||
>
|
||||
<span>
|
||||
This {u.kind} references the resource by a hardcoded path
|
||||
<span class="break-all font-mono">$res:{u.path}</span>.
|
||||
</span>
|
||||
<span class="text-hint">
|
||||
For portability, prefer taking the resource as an input — a fork
|
||||
won't have this exact path. It's relocated into the project on
|
||||
publish, but converting it to an input keeps the item reusable.
|
||||
</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{TRIGGER_KIND_BADGE[u.triggerKind]} trigger
|
||||
</span>
|
||||
{#if itemUrl}
|
||||
<a
|
||||
href={itemUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="Open {u.kind} in new tab"
|
||||
class="shrink-0 text-hint hover:text-primary"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@const itemUrl = openItemUrl(u.kind, u.itemPath)}
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if u.kind === 'script'}
|
||||
<Code2 size={14} class="shrink-0 text-hint" />
|
||||
{:else if u.kind === 'flow'}
|
||||
<BarsStaggered size={14} class="shrink-0 text-hint" />
|
||||
{:else}
|
||||
<LayoutDashboard size={14} class="shrink-0 text-hint" />
|
||||
{/if}
|
||||
<span class="break-all font-mono text-primary">{u.label}</span>
|
||||
<span
|
||||
class="ml-auto inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide {u.role ===
|
||||
'hardcoded'
|
||||
? 'bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-200'
|
||||
: 'bg-surface text-secondary'}"
|
||||
>
|
||||
{u.role === 'hardcoded' ? 'hardcoded path' : 'input'}
|
||||
{#if u.role === 'hardcoded'}
|
||||
<Popover notClickable placement="top">
|
||||
<Info size={11} class="text-amber-600 dark:text-amber-400" />
|
||||
{#snippet text()}
|
||||
<div
|
||||
class="flex w-80 max-w-[90vw] flex-col gap-2 text-left text-[11px] normal-case"
|
||||
>
|
||||
<span>
|
||||
This {u.kind} references the resource by a hardcoded path
|
||||
<span class="break-all font-mono">$res:{u.path}</span>.
|
||||
</span>
|
||||
<span class="text-hint">
|
||||
For portability, prefer taking the resource as an input — a fork
|
||||
won't have this exact path. It's relocated into the project on
|
||||
publish, but converting it to an input keeps the item reusable.
|
||||
</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</span>
|
||||
{#if itemUrl}
|
||||
<a
|
||||
href={itemUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="Open {u.kind} in new tab"
|
||||
class="shrink-0 text-hint hover:text-primary"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -213,6 +213,8 @@ export interface ResourceStub {
|
||||
export interface ProjectBundle {
|
||||
items: BundledItem[]
|
||||
resourceStubs: ResourceStub[]
|
||||
/** Original -> relocated path for every item and resource (incl. unresolved). */
|
||||
pathMap: Map<string, string>
|
||||
/** External paths we couldn't fetch/resolve (missing items or untyped resources). */
|
||||
unresolved: string[]
|
||||
}
|
||||
@@ -230,7 +232,8 @@ function refsForFetched(item: FetchedItem): Ref[] {
|
||||
export async function buildProjectBundle(
|
||||
seed: ItemRef[],
|
||||
slug: string,
|
||||
deps: BundleDeps
|
||||
deps: BundleDeps,
|
||||
extraResourcePaths: string[] = []
|
||||
): Promise<ProjectBundle> {
|
||||
const fetched = new Map<string, FetchedItem>()
|
||||
const queued = new Set<string>(seed.map((s) => s.path))
|
||||
@@ -238,6 +241,11 @@ export async function buildProjectBundle(
|
||||
const resourcePaths = new Set<string>()
|
||||
const unresolved: string[] = []
|
||||
|
||||
// Resources referenced by triggers (by config path, not `$res:` in code).
|
||||
for (const p of extraResourcePaths) {
|
||||
if (classifyPath(p, slug) !== 'hub') resourcePaths.add(p)
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const ref = queue.shift()!
|
||||
if (fetched.has(ref.path)) continue
|
||||
@@ -286,5 +294,5 @@ export async function buildProjectBundle(
|
||||
resourceStubs.push({ originalPath: path, newPath: map.get(path) ?? path, resource_type: type })
|
||||
}
|
||||
|
||||
return { items, resourceStubs, unresolved }
|
||||
return { items, resourceStubs, pathMap: map, unresolved }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user