diff --git a/backend/windmill-api/src/hub_publish.rs b/backend/windmill-api/src/hub_publish.rs index e668c0e655..9475c1a57d 100644 --- a/backend/windmill-api/src/hub_publish.rs +++ b/backend/windmill-api/src/hub_publish.rs @@ -31,6 +31,8 @@ pub fn workspaced_service() -> Router { .route("/resources", post(publish_resources)) .route("/triggers", post(publish_triggers)) .route("/projects/{slug}/export", get(get_project_export)) + .route("/projects/{slug}/submit", post(submit_project)) + .route("/project", get(get_project_by_source)) } fn validate_project_slug(slug: &str) -> Result<(), Error> { @@ -101,6 +103,8 @@ struct PublishScriptBody { lockfile: Option, #[serde(skip_serializing_if = "Option::is_none")] path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + source_path: Option, project_slug: String, } @@ -129,6 +133,8 @@ struct PublishFlowBody { apps: Vec, #[serde(skip_serializing_if = "Option::is_none")] path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + source_path: Option, project_slug: String, #[serde(skip_serializing_if = "Option::is_none")] recording: Option, @@ -152,6 +158,8 @@ struct PublishAppBody { summary: String, #[serde(skip_serializing_if = "Option::is_none")] path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + source_path: Option, project_slug: String, } @@ -175,6 +183,8 @@ struct PublishRawAppBody { external_embed_url: Option, #[serde(skip_serializing_if = "Option::is_none")] path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + source_path: Option, project_slug: String, } @@ -329,6 +339,28 @@ async fn get_project_export( get_from_hub(&format!("/projects/{}/export", slug), &workspace).await } +async fn get_project_by_source( + authed: ApiAuthed, + Path(workspace): Path, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + get_from_hub("/projects/by_source", &workspace).await +} + +async fn submit_project( + authed: ApiAuthed, + Path((workspace, slug)): Path<(String, String)>, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + validate_project_slug(&slug)?; + forward_to_hub( + &format!("/projects/{}/submit", slug), + &workspace, + &serde_json::json!({}), + ) + .await +} + async fn get_from_hub(path: &str, source_id: &str) -> Result<(StatusCode, String), Error> { let hub_token = std::env::var("HUB_DEV_TOKEN") .map_err(|_| Error::BadRequest("HUB_DEV_TOKEN not set".into()))?; diff --git a/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte b/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte index 34343186df..303ffeca74 100644 --- a/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte +++ b/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte @@ -187,7 +187,6 @@ const EMPTY_SCHEMA = { type: 'object', properties: {}, required: [] } - let hubVersion = $state(0) let deploymentStatus = $state< Record >({}) @@ -385,6 +384,37 @@ } } + async function rehydrateFromHub(workspace: string, seq: number) { + try { + const res = await fetch(`/api/w/${workspace}/hub/project`, { + credentials: 'include', + headers: { accept: 'application/json' } + }) + if (seq !== workspaceLoadSeq) return + if (!res.ok) return // 404 = no project published for this workspace yet + const p = JSON.parse(await res.text()) + if (seq !== workspaceLoadSeq || !p?.slug) return + effectiveSlug = p.slug + hubName = p.name ?? '' + hubSummary = p.summary ?? '' + hubReadme = p.readme ?? '' + phase = p.status === 'live' ? 'live' : p.status === 'under_review' ? 'under_review' : 'draft' + const ids: Record = {} + draftItems = (p.items ?? []).map((it: any) => { + const wpath = it.source_path ?? it.path + const key = `${it.kind}:${wpath}` + if (typeof it.hub_id === 'number') ids[key] = it.hub_id + return { + key, + path: wpath, + kind: it.kind as Kind, + rec: it.has_recording ? 'recorded' : 'none' + } satisfies DeployItem + }) + hubItemIds = ids + } catch {} + } + $effect(() => { if ($workspaceStore) { const seq = ++workspaceLoadSeq @@ -400,7 +430,6 @@ deploymentStatus = {} hubItemIds = {} effectiveSlug = '' - hubVersion = 0 hubName = '' hubSummary = '' hubReadme = '' @@ -409,6 +438,7 @@ bundleReadme = '' loadWorkspace($workspaceStore, seq) loadTriggers($workspaceStore, seq) + rehydrateFromHub($workspaceStore, seq) } }) @@ -722,6 +752,7 @@ schema: it.schema ?? undefined, lockfile: it.lock ?? undefined, path: it.newPath, + source_path: it.path, project_slug: slug }) if (typeof resp?.id === 'number') hubItemIds = { ...hubItemIds, [key]: resp.id } @@ -735,6 +766,7 @@ }, apps: [], path: it.newPath, + source_path: it.path, project_slug: slug }) if (typeof resp?.id === 'number') hubItemIds = { ...hubItemIds, [key]: resp.id } @@ -745,6 +777,7 @@ summary: it.summary || it.newPath, description: undefined, path: it.newPath, + source_path: it.path, project_slug: slug }) } else if (it.kind === 'raw_app') { @@ -753,6 +786,7 @@ apps: [], summary: it.summary || it.newPath, path: it.newPath, + source_path: it.path, description: undefined, project_slug: slug }) @@ -1074,11 +1108,7 @@ if (failures > 0) { sendUserToast(`Draft pushed with ${failures} failed item(s).`, true) } else { - sendUserToast( - hubVersion === 0 - ? `Draft created on the Hub. Add recordings before submitting for review.` - : `New draft created (will become v${hubVersion + 1} after review).` - ) + sendUserToast(`Draft created on the Hub. Add recordings before submitting for review.`) } } finally { deploying = false @@ -1087,9 +1117,24 @@ let submitting = $state(false) async function submitForReview() { + const workspace = $workspaceStore + const slug = effectiveSlug || sanitizeSlug(hubName) + if (!workspace || !slug) return submitting = true try { - await delay(400) + const res = await fetch( + `/api/w/${workspace}/hub/projects/${encodeURIComponent(slug)}/submit`, + { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: '{}' + } + ) + if (!res.ok) { + sendUserToast(`Submit for review failed: ${await res.text()}`, true) + return + } phase = 'under_review' sendUserToast('Submitted for review by the Windmill team.') } finally { @@ -1133,7 +1178,7 @@ draftItems = filteredWorkspaceItems.map((i) => ({ ...i, rec: 'none' })) recordings = {} phase = 'draft' - sendUserToast(`New draft started (will become v${hubVersion + 1}).`) + sendUserToast(`New draft started.`) } async function openRecord(it: DeployItem) { @@ -1457,7 +1502,7 @@ {:else if phase === 'under_review'} Step 3: Awaiting review {:else} - Live on the Hub — v{hubVersion} + Live on the Hub {/if} {#if phase !== 'predeploy'} @@ -1774,11 +1819,9 @@ onclick={syncWithHub} /> {:else} - - Iterate further by starting a new draft for v{hubVersion + 1}. - + Iterate further by starting a new draft. {/if} @@ -1795,8 +1838,8 @@

Run this {recordTarget?.kind} once with the inputs below. The full execution — args, logs, intermediate step outputs and final result — is saved as a replayable recording shown on the Hub - page for v{hubVersion}. Visitors can step through it to see how the {recordTarget?.kind} works - without running anything themselves. + page. Visitors can step through it to see how the {recordTarget?.kind} works without running + anything themselves.

{#if runState !== 'idle'} @@ -1845,7 +1888,7 @@ class="mt-1 flex items-center justify-between gap-3 rounded-md border border-green-300 bg-green-50 p-2 dark:border-green-800 dark:bg-green-950/40" > - Looks good? Save this run as the Hub recording for v{hubVersion}. + Looks good? Save this run as the Hub recording.