mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-28 16:02:47 +00:00
fix(deploy-to-hub): gate hub publish endpoints + harden trigger detection
- Add ApiAuthed + require_admin to all hub publish handlers; previously any workspace-authenticated session could trigger Hub-side writes attributed to the URL workspace via the shared HUB_DEV_TOKEN. - Track per-kind trigger fetch failures (triggerLoadErrors) so an EE-gated or transiently failing trigger service no longer silently maps to "0 triggers"; UI surfaces an amber badge listing the missing kinds and a toast warns the operator before publish. - Add workspaceLoadSeq cancellation so the parallel loadWorkspace + loadTriggers stop bleeding stale data when the workspace switches mid load. - Drop the silent effectiveSlug fallback to sanitizeSlug(hubName) when the Hub response can't be parsed; abort the publish instead so items don't land under a slug the Hub never locked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a1ceb19c49
commit
16eae212cc
@@ -1,3 +1,4 @@
|
||||
use crate::db::ApiAuthed;
|
||||
use crate::HTTP_CLIENT;
|
||||
use axum::{
|
||||
extract::{Json, Path},
|
||||
@@ -7,7 +8,7 @@ use axum::{
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use windmill_common::{error::Error, HUB_BASE_URL};
|
||||
use windmill_common::{error::Error, utils::require_admin, HUB_BASE_URL};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
@@ -36,6 +37,7 @@ struct PublishDraftBody {
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct HubWorkspaceBody {
|
||||
source_id: String,
|
||||
slug: String,
|
||||
name: String,
|
||||
summary: String,
|
||||
@@ -44,12 +46,15 @@ struct HubWorkspaceBody {
|
||||
}
|
||||
|
||||
async fn publish_draft(
|
||||
Path(_workspace): Path<String>,
|
||||
authed: ApiAuthed,
|
||||
Path(workspace): Path<String>,
|
||||
Json(body): Json<PublishDraftBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
forward_to_hub(
|
||||
"/workspaces",
|
||||
&HubWorkspaceBody {
|
||||
source_id: workspace,
|
||||
slug: body.slug,
|
||||
name: body.name,
|
||||
summary: body.summary,
|
||||
@@ -77,9 +82,11 @@ struct PublishScriptBody {
|
||||
}
|
||||
|
||||
async fn publish_script(
|
||||
authed: ApiAuthed,
|
||||
Path(_workspace): Path<String>,
|
||||
Json(body): Json<PublishScriptBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
forward_to_hub("/scripts/add", &body).await
|
||||
}
|
||||
|
||||
@@ -103,9 +110,11 @@ struct PublishFlowBody {
|
||||
}
|
||||
|
||||
async fn publish_flow(
|
||||
authed: ApiAuthed,
|
||||
Path(_workspace): Path<String>,
|
||||
Json(body): Json<PublishFlowBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
forward_to_hub("/flows", &body).await
|
||||
}
|
||||
|
||||
@@ -120,9 +129,11 @@ struct PublishAppBody {
|
||||
}
|
||||
|
||||
async fn publish_app(
|
||||
authed: ApiAuthed,
|
||||
Path(_workspace): Path<String>,
|
||||
Json(body): Json<PublishAppBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
forward_to_hub("/apps", &body).await
|
||||
}
|
||||
|
||||
@@ -139,9 +150,11 @@ struct PublishRawAppBody {
|
||||
}
|
||||
|
||||
async fn publish_raw_app(
|
||||
authed: ApiAuthed,
|
||||
Path(_workspace): Path<String>,
|
||||
Json(body): Json<PublishRawAppBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
forward_to_hub("/raw_apps", &body).await
|
||||
}
|
||||
|
||||
@@ -153,16 +166,20 @@ struct RecordingBody {
|
||||
}
|
||||
|
||||
async fn publish_script_recording(
|
||||
authed: ApiAuthed,
|
||||
Path((_workspace, ask_id)): Path<(String, i64)>,
|
||||
Json(body): Json<RecordingBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
forward_to_hub(&format!("/scripts/{}/recording", ask_id), &body).await
|
||||
}
|
||||
|
||||
async fn publish_flow_recording(
|
||||
authed: ApiAuthed,
|
||||
Path((_workspace, flow_id)): Path<(String, i64)>,
|
||||
Json(body): Json<RecordingBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
forward_to_hub(&format!("/flows/{}/recording", flow_id), &body).await
|
||||
}
|
||||
|
||||
@@ -179,9 +196,11 @@ struct PublishResourceTypeBody {
|
||||
}
|
||||
|
||||
async fn publish_resource_type(
|
||||
authed: ApiAuthed,
|
||||
Path(_workspace): Path<String>,
|
||||
Json(body): Json<PublishResourceTypeBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
forward_to_hub(
|
||||
&format!("/workspaces/{}/resource_types", body.workspace_slug),
|
||||
&body,
|
||||
@@ -204,9 +223,11 @@ struct PublishResourcesBody {
|
||||
}
|
||||
|
||||
async fn publish_resources(
|
||||
authed: ApiAuthed,
|
||||
Path(_workspace): Path<String>,
|
||||
Json(body): Json<PublishResourcesBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
forward_to_hub(
|
||||
&format!("/workspaces/{}/resources", body.workspace_slug),
|
||||
&body,
|
||||
|
||||
@@ -22,7 +22,18 @@
|
||||
RawAppService,
|
||||
ResourceService,
|
||||
ScriptService,
|
||||
WorkspaceService
|
||||
WorkspaceService,
|
||||
HttpTriggerService,
|
||||
WebsocketTriggerService,
|
||||
KafkaTriggerService,
|
||||
NatsTriggerService,
|
||||
SqsTriggerService,
|
||||
MqttTriggerService,
|
||||
GcpTriggerService,
|
||||
AzureTriggerService,
|
||||
PostgresTriggerService,
|
||||
EmailTriggerService,
|
||||
ScheduleService
|
||||
} from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { computeSecretUrl } from '$lib/components/apps/editor/appDeploy.svelte'
|
||||
@@ -41,17 +52,20 @@
|
||||
import {
|
||||
Check,
|
||||
Cloud,
|
||||
Code2,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
GitCompare,
|
||||
Globe,
|
||||
Info,
|
||||
LayoutDashboard,
|
||||
Loader2,
|
||||
Play,
|
||||
RotateCcw,
|
||||
TriangleAlert,
|
||||
X
|
||||
} from 'lucide-svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import type { Kind } from '$lib/utils_deployable'
|
||||
|
||||
@@ -71,11 +85,65 @@
|
||||
const canRecord = (k: Kind) => k === 'script' || k === 'flow'
|
||||
const canPublishApp = (k: Kind) => k === 'app' || k === 'raw_app'
|
||||
|
||||
type TriggerKindLabel =
|
||||
| 'http'
|
||||
| 'websocket'
|
||||
| 'schedule'
|
||||
| 'kafka'
|
||||
| 'nats'
|
||||
| 'sqs'
|
||||
| 'mqtt'
|
||||
| 'gcp'
|
||||
| 'azure'
|
||||
| 'postgres'
|
||||
| 'email'
|
||||
interface WorkspaceTrigger {
|
||||
kind: TriggerKindLabel
|
||||
path: string
|
||||
script_path: string
|
||||
is_flow: boolean
|
||||
summary?: string
|
||||
config: Record<string, unknown>
|
||||
}
|
||||
const TRIGGER_KIND_BADGE: Record<TriggerKindLabel, string> = {
|
||||
http: 'HTTP',
|
||||
websocket: 'WebSocket',
|
||||
schedule: 'Schedule',
|
||||
kafka: 'Kafka',
|
||||
nats: 'NATS',
|
||||
sqs: 'SQS',
|
||||
mqtt: 'MQTT',
|
||||
gcp: 'GCP Pub/Sub',
|
||||
azure: 'Azure',
|
||||
postgres: 'Postgres',
|
||||
email: 'Email'
|
||||
}
|
||||
const TRIGGER_KIND_NOTE: Partial<Record<TriggerKindLabel, string>> = {
|
||||
http: 'Webhook URL regenerates on import — re-register with the external service.',
|
||||
websocket: 'Reconnect WebSocket auth after import if external service requires it.',
|
||||
email: 'Email address regenerates on import.',
|
||||
kafka: 'Verify Kafka broker access from the importing instance.',
|
||||
nats: 'Verify NATS connection from the importing instance.',
|
||||
gcp: 'Re-link GCP Pub/Sub subscription after import.',
|
||||
azure: 'Re-link Azure Event Grid subscription after import.'
|
||||
}
|
||||
|
||||
let phase = $state<Phase>('predeploy')
|
||||
// Live workspace items (refreshed from API). Used in predeploy phase only.
|
||||
let workspaceItems = $state<DeployItem[]>([])
|
||||
// Snapshot frozen at deploy time. Used in draft / under_review / live phases.
|
||||
let draftItems = $state<DeployItem[]>([])
|
||||
let workspaceTriggers = $state<WorkspaceTrigger[]>([])
|
||||
let triggersLoading = $state(false)
|
||||
let triggerLoadErrors = $state<Set<TriggerKindLabel>>(new Set())
|
||||
let workspaceLoadSeq = 0
|
||||
let schedulePreviews = $state<Record<string, string[]>>({})
|
||||
const schedulePreviewsInFlight = new Set<string>()
|
||||
const dateFmt = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
})
|
||||
|
||||
let selectedFolders = $state<string[]>([])
|
||||
let availableFolders = $derived(
|
||||
Array.from(
|
||||
@@ -160,6 +228,7 @@
|
||||
let publishing = $state(false)
|
||||
|
||||
let resourceDrawer = $state<Drawer | undefined>()
|
||||
let triggerDrawer = $state<Drawer | undefined>()
|
||||
let bundleDrawer = $state<Drawer | undefined>()
|
||||
let bundleName = $state('')
|
||||
let bundleSummary = $state('')
|
||||
@@ -169,6 +238,8 @@
|
||||
let hubSummary = $state('')
|
||||
let hubReadme = $state('')
|
||||
|
||||
let effectiveSlug = $state('')
|
||||
|
||||
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
function patchItem(key: string, patch: Partial<DeployItem>) {
|
||||
@@ -189,7 +260,7 @@
|
||||
return out
|
||||
}
|
||||
|
||||
async function loadWorkspace(workspace: string) {
|
||||
async function loadWorkspace(workspace: string, seq: number) {
|
||||
loading = true
|
||||
try {
|
||||
const [apps, rawApps, flows, scripts, settings] = await Promise.all([
|
||||
@@ -199,6 +270,7 @@
|
||||
listAllPages((p) => ScriptService.listScripts({ workspace, ...p })),
|
||||
WorkspaceService.getSettings({ workspace }).catch(() => undefined)
|
||||
])
|
||||
if (seq !== workspaceLoadSeq) return
|
||||
|
||||
workspaceRateLimit = settings?.public_app_execution_limit_per_minute
|
||||
|
||||
@@ -250,11 +322,81 @@
|
||||
// Resources are NOT selectable items — they're dependencies derived from
|
||||
// the $res: references in the selected scripts/flows/apps and shown
|
||||
// read-only (see detectedResources).
|
||||
if (seq !== workspaceLoadSeq) return
|
||||
workspaceItems = next
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Failed to load workspace items: ${e?.message ?? e}`, true)
|
||||
if (seq === workspaceLoadSeq) {
|
||||
sendUserToast(`Failed to load workspace items: ${e?.message ?? e}`, true)
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
if (seq === workspaceLoadSeq) loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTriggers(workspace: string, seq: number) {
|
||||
triggersLoading = true
|
||||
const errors = new Set<TriggerKindLabel>()
|
||||
try {
|
||||
const listOrMark = async <T,>(kind: TriggerKindLabel, p: Promise<T[]>): Promise<T[]> => {
|
||||
try {
|
||||
return await p
|
||||
} catch {
|
||||
errors.add(kind)
|
||||
return []
|
||||
}
|
||||
}
|
||||
const [http, websocket, schedule, kafka, nats, sqs, mqtt, gcp, azure, postgres, email] =
|
||||
await Promise.all([
|
||||
listOrMark('http', HttpTriggerService.listHttpTriggers({ workspace })),
|
||||
listOrMark('websocket', WebsocketTriggerService.listWebsocketTriggers({ workspace })),
|
||||
listOrMark('schedule', ScheduleService.listSchedules({ workspace })),
|
||||
listOrMark('kafka', KafkaTriggerService.listKafkaTriggers({ workspace })),
|
||||
listOrMark('nats', NatsTriggerService.listNatsTriggers({ workspace })),
|
||||
listOrMark('sqs', SqsTriggerService.listSqsTriggers({ workspace })),
|
||||
listOrMark('mqtt', MqttTriggerService.listMqttTriggers({ workspace })),
|
||||
listOrMark('gcp', GcpTriggerService.listGcpTriggers({ workspace })),
|
||||
listOrMark('azure', AzureTriggerService.listAzureTriggers({ workspace })),
|
||||
listOrMark('postgres', PostgresTriggerService.listPostgresTriggers({ workspace })),
|
||||
listOrMark('email', EmailTriggerService.listEmailTriggers({ workspace }))
|
||||
])
|
||||
if (seq !== workspaceLoadSeq) return
|
||||
const normalize = (
|
||||
kind: TriggerKindLabel,
|
||||
rows: Array<Record<string, any>>
|
||||
): WorkspaceTrigger[] =>
|
||||
rows.map((r) => ({
|
||||
kind,
|
||||
path: r.path,
|
||||
script_path: r.script_path,
|
||||
is_flow: r.is_flow ?? false,
|
||||
summary: r.summary,
|
||||
config: r
|
||||
}))
|
||||
workspaceTriggers = [
|
||||
...normalize('http', http),
|
||||
...normalize('websocket', websocket),
|
||||
...normalize('schedule', schedule),
|
||||
...normalize('kafka', kafka),
|
||||
...normalize('nats', nats),
|
||||
...normalize('sqs', sqs),
|
||||
...normalize('mqtt', mqtt),
|
||||
...normalize('gcp', gcp),
|
||||
...normalize('azure', azure),
|
||||
...normalize('postgres', postgres),
|
||||
...normalize('email', email)
|
||||
]
|
||||
triggerLoadErrors = errors
|
||||
if (errors.size > 0) {
|
||||
const labels = Array.from(errors)
|
||||
.map((k) => TRIGGER_KIND_BADGE[k])
|
||||
.join(', ')
|
||||
sendUserToast(
|
||||
`Could not list triggers for: ${labels}. The bundle may miss stubs from those kinds.`,
|
||||
true
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (seq === workspaceLoadSeq) triggersLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,22 +411,148 @@
|
||||
|
||||
$effect(() => {
|
||||
if ($workspaceStore) {
|
||||
loadWorkspace($workspaceStore)
|
||||
const seq = ++workspaceLoadSeq
|
||||
// Drop stale workspace state immediately so prior data can't bleed
|
||||
// into the new workspace before the parallel fetches resolve.
|
||||
workspaceItems = []
|
||||
workspaceTriggers = []
|
||||
triggerLoadErrors = new Set()
|
||||
schedulePreviews = {}
|
||||
loadWorkspace($workspaceStore, seq)
|
||||
loadTriggers($workspaceStore, seq)
|
||||
}
|
||||
})
|
||||
|
||||
let relevantTriggers = $derived.by(() => {
|
||||
const selectedScripts = new Set(
|
||||
selectedItems.filter((i) => i.kind === 'script').map((i) => i.path)
|
||||
)
|
||||
const selectedFlows = new Set(selectedItems.filter((i) => i.kind === 'flow').map((i) => i.path))
|
||||
return workspaceTriggers.filter((t) =>
|
||||
t.is_flow ? selectedFlows.has(t.script_path) : selectedScripts.has(t.script_path)
|
||||
)
|
||||
})
|
||||
|
||||
let triggersByKind = $derived.by(() => {
|
||||
const out = new Map<TriggerKindLabel, WorkspaceTrigger[]>()
|
||||
for (const t of relevantTriggers) {
|
||||
const arr = out.get(t.kind) ?? []
|
||||
arr.push(t)
|
||||
out.set(t.kind, arr)
|
||||
}
|
||||
return Array.from(out.entries()).sort((a, b) => a[0].localeCompare(b[0]))
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
for (const t of relevantTriggers) {
|
||||
if (t.kind !== 'schedule') continue
|
||||
const c = t.config as any
|
||||
const key = `${c.schedule}|${c.timezone}`
|
||||
if (schedulePreviews[key] || schedulePreviewsInFlight.has(key)) continue
|
||||
schedulePreviewsInFlight.add(key)
|
||||
ScheduleService.previewSchedule({
|
||||
requestBody: {
|
||||
schedule: c.schedule,
|
||||
timezone: c.timezone,
|
||||
cron_version: c.cron_version ?? 'v2'
|
||||
}
|
||||
})
|
||||
.then((dates) => {
|
||||
schedulePreviews = { ...schedulePreviews, [key]: dates.slice(0, 3) }
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => schedulePreviewsInFlight.delete(key))
|
||||
}
|
||||
})
|
||||
|
||||
function triggerDetails(t: WorkspaceTrigger): Array<{ label: string; value: string }> {
|
||||
const c = t.config as any
|
||||
const out: Array<{ label: string; value: string }> = []
|
||||
const push = (label: string, v: any) => {
|
||||
if (v != null && v !== '') out.push({ label, value: String(v) })
|
||||
}
|
||||
switch (t.kind) {
|
||||
case 'http':
|
||||
push('Route', `${(c.http_method ?? '').toUpperCase()} /${c.route_path ?? ''}`)
|
||||
push('Auth', c.authentication_method)
|
||||
break
|
||||
case 'schedule':
|
||||
push('Cron', c.schedule)
|
||||
push('Timezone', c.timezone)
|
||||
break
|
||||
case 'websocket':
|
||||
push('URL', c.url)
|
||||
break
|
||||
case 'kafka':
|
||||
push('Resource', c.kafka_resource_path)
|
||||
push('Group', c.group_id)
|
||||
push('Topics', (c.topics ?? []).join(', '))
|
||||
break
|
||||
case 'nats':
|
||||
push('Resource', c.nats_resource_path)
|
||||
push('Subjects', (c.subjects ?? []).join(', '))
|
||||
push('Jetstream', c.use_jetstream)
|
||||
break
|
||||
case 'sqs':
|
||||
push('Queue', c.queue_url)
|
||||
push('Resource', c.aws_resource_path)
|
||||
break
|
||||
case 'mqtt':
|
||||
push('Resource', c.mqtt_resource_path)
|
||||
push('Topics', (c.subscribe_topics ?? []).map((x: any) => x?.topic ?? x).join(', '))
|
||||
break
|
||||
case 'gcp':
|
||||
push('Resource', c.gcp_resource_path)
|
||||
push('Topic', c.topic_id)
|
||||
push('Subscription', c.subscription_id)
|
||||
break
|
||||
case 'azure':
|
||||
push('Resource', c.azure_resource_path)
|
||||
push('Scope', c.scope_resource_id)
|
||||
push('Subscription', c.subscription_name)
|
||||
break
|
||||
case 'postgres':
|
||||
push('Resource', c.postgres_resource_path)
|
||||
push('Publication', c.publication_name)
|
||||
break
|
||||
case 'email':
|
||||
push('Local part', c.local_part)
|
||||
break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
let runnableSummaryByPath = $derived.by(() => {
|
||||
const m = new Map<string, string | undefined>()
|
||||
for (const it of workspaceItems) {
|
||||
if (it.kind === 'script' || it.kind === 'flow') {
|
||||
m.set(`${it.kind}:${it.path}`, it.summary)
|
||||
}
|
||||
}
|
||||
return m
|
||||
})
|
||||
|
||||
function openBundle() {
|
||||
bundleName = hubName
|
||||
bundleSummary = hubSummary
|
||||
bundleReadme = hubReadme
|
||||
bundleDrawer?.openDrawer()
|
||||
}
|
||||
// Mirror the Hub's SLUG_RE: ^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$
|
||||
// (3-50 chars, alphanumeric start/end, dashes only in the middle).
|
||||
function sanitizeSlug(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/[_\s]+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 50)
|
||||
.replace(/-+$/g, '')
|
||||
}
|
||||
const SLUG_RE = /^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$/
|
||||
function isValidSlug(s: string): boolean {
|
||||
return SLUG_RE.test(s)
|
||||
}
|
||||
|
||||
async function confirmBundle() {
|
||||
@@ -299,17 +567,34 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
slug: sanitizeSlug(hubSlug),
|
||||
slug: effectiveSlug || sanitizeSlug(hubName),
|
||||
name: hubName,
|
||||
summary: hubSummary || hubName,
|
||||
readme: hubReadme || undefined
|
||||
})
|
||||
})
|
||||
const text = await res.text()
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
sendUserToast(`Hub draft creation failed: ${text}`, true)
|
||||
return
|
||||
}
|
||||
// The Hub returns the effective slug (locked once a project exists). If the
|
||||
// response can't be parsed or omits the slug, abort instead of guessing —
|
||||
// proceeding with a client-computed slug risks landing items under a
|
||||
// folder the Hub never created (proxy-rewritten body, server-side
|
||||
// collision suffix, etc.).
|
||||
let returnedSlug: string | undefined
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
if (typeof parsed?.slug === 'string') returnedSlug = parsed.slug
|
||||
} catch {
|
||||
// fallthrough — handled below
|
||||
}
|
||||
if (!returnedSlug) {
|
||||
sendUserToast(`Hub did not return a slug. Aborting publish to avoid path drift.`, true)
|
||||
return
|
||||
}
|
||||
effectiveSlug = returnedSlug
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Hub draft creation failed: ${e?.message ?? e}`, true)
|
||||
return
|
||||
@@ -529,7 +814,7 @@
|
||||
}
|
||||
let cancelled = false
|
||||
detectingResources = true
|
||||
const slug = sanitizeSlug(hubSlug)
|
||||
const slug = effectiveSlug || sanitizeSlug(hubName)
|
||||
const seed: ItemRef[] = selectedItems
|
||||
.filter((i) => i.kind !== 'resource')
|
||||
.map((i) => ({ kind: i.kind as ItemRef['kind'], path: i.path }))
|
||||
@@ -584,7 +869,8 @@
|
||||
async function deployAll() {
|
||||
const workspace = $workspaceStore
|
||||
if (!workspace) return
|
||||
const slug = sanitizeSlug(hubSlug)
|
||||
// Use the Hub's effective (locked) slug so relocated paths match the project.
|
||||
const slug = effectiveSlug || sanitizeSlug(hubName)
|
||||
deploying = true
|
||||
let failures = 0
|
||||
try {
|
||||
@@ -695,7 +981,7 @@
|
||||
// MOCK: would diff the live workspace against the submitted bundle and open a viewer.
|
||||
const workspace = $workspaceStore
|
||||
if (!workspace) return
|
||||
await loadWorkspace(workspace)
|
||||
await loadWorkspace(workspace, workspaceLoadSeq)
|
||||
const draftKeys = new Set(draftItems.map((i) => i.key))
|
||||
const workspaceKeys = new Set(workspaceItems.map((i) => i.key))
|
||||
const added = workspaceItems.filter((i) => !draftKeys.has(i.key)).length
|
||||
@@ -710,7 +996,7 @@
|
||||
if (!workspace) return
|
||||
syncing = true
|
||||
try {
|
||||
await loadWorkspace(workspace)
|
||||
await loadWorkspace(workspace, workspaceLoadSeq)
|
||||
if (phase === 'draft') {
|
||||
const prev = new Map(draftItems.map((i) => [i.key, { rec: i.rec }]))
|
||||
draftItems = workspaceItems
|
||||
@@ -912,7 +1198,7 @@
|
||||
const path = it.kind === 'script' ? 'scripts' : 'flows'
|
||||
await postHub(workspace, `/hub/${path}/${hubId}/recording`, {
|
||||
recording,
|
||||
workspace_slug: sanitizeSlug(hubSlug)
|
||||
workspace_slug: effectiveSlug || sanitizeSlug(hubName)
|
||||
})
|
||||
recordings = { ...recordings, [it.key]: runJobId }
|
||||
patchItem(it.key, { rec: 'recorded' })
|
||||
@@ -1071,13 +1357,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if phase === 'predeploy'}
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-semibold text-primary">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class="font-semibold text-primary shrink-0">
|
||||
Resource dependencies
|
||||
{#if detectingResources}
|
||||
<Loader2 size={11} class="inline animate-spin text-hint" />
|
||||
{:else}
|
||||
<span class="text-hint">({dependencyTypes.length})</span>
|
||||
<span class="text-hint font-normal">({dependencyTypes.length})</span>
|
||||
{/if}
|
||||
<Tooltip>
|
||||
Resource types the selected items depend on (whether passed as inputs or referenced
|
||||
@@ -1090,26 +1376,66 @@
|
||||
No resource references detected in the current selection.
|
||||
</span>
|
||||
{:else}
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-1.5 rounded-md border bg-surface-secondary p-2"
|
||||
>
|
||||
{#each dependencyTypes as r (r.resource_type)}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded border px-1.5 py-0.5 font-mono text-[11px] text-secondary {r.hasHardcoded
|
||||
? 'border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/40'
|
||||
: 'bg-surface'}"
|
||||
>
|
||||
{r.resource_type}
|
||||
</span>
|
||||
{/each}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
onclick={() => resourceDrawer?.openDrawer()}
|
||||
{#each dependencyTypes as r (r.resource_type)}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded border px-1.5 py-0.5 font-mono text-[11px] text-secondary {r.hasHardcoded
|
||||
? 'border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/40'
|
||||
: 'bg-surface'}"
|
||||
>
|
||||
View details
|
||||
</Button>
|
||||
</div>
|
||||
{r.resource_type}
|
||||
</span>
|
||||
{/each}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
wrapperClasses="ml-auto"
|
||||
onclick={() => resourceDrawer?.openDrawer()}
|
||||
>
|
||||
View details
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if phase === 'predeploy'}
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class="font-semibold text-primary shrink-0">
|
||||
Triggers
|
||||
{#if triggersLoading}
|
||||
<Loader2 size={11} class="inline animate-spin text-hint" />
|
||||
{:else}
|
||||
<span class="text-hint font-normal">({relevantTriggers.length})</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if triggerLoadErrors.size > 0}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded border border-amber-300 bg-amber-50 px-1.5 py-0.5 text-[11px] text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100"
|
||||
>
|
||||
<TriangleAlert size={11} />
|
||||
Could not list:
|
||||
{Array.from(triggerLoadErrors)
|
||||
.map((k) => TRIGGER_KIND_BADGE[k])
|
||||
.join(', ')}
|
||||
</span>
|
||||
{/if}
|
||||
{#if relevantTriggers.length === 0}
|
||||
<span class="text-[11px] text-hint">No triggers reference the selected items.</span>
|
||||
{:else}
|
||||
{#each triggersByKind as [kind, triggers] (kind)}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded border bg-surface px-1.5 py-0.5 font-mono text-[11px] text-secondary"
|
||||
>
|
||||
{TRIGGER_KIND_BADGE[kind]}
|
||||
<span class="text-hint">×{triggers.length}</span>
|
||||
</span>
|
||||
{/each}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
wrapperClasses="ml-auto"
|
||||
onclick={() => triggerDrawer?.openDrawer()}
|
||||
>
|
||||
View details
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1506,7 +1832,13 @@
|
||||
{#each r.usages as u (u.role + ':' + u.label + ':' + (u.role === 'hardcoded' ? u.path : ''))}
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-hint">{u.kind}</span>
|
||||
{#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 ===
|
||||
@@ -1547,6 +1879,101 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer bind:this={triggerDrawer} size="640px">
|
||||
<DrawerContent title="Triggers" on:close={() => triggerDrawer?.closeDrawer()}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-xs text-secondary">
|
||||
Triggers attached to the selected scripts and flows. Synced to the Hub as
|
||||
<span class="font-semibold">disabled stubs</span>. Recipients review and enable each one
|
||||
manually after importing. External hooks (Slack/Discord webhooks, message-queue
|
||||
subscriptions, etc.) must be re-registered against the importing instance.
|
||||
</p>
|
||||
{#if relevantTriggers.length === 0}
|
||||
<span class="text-xs text-hint">No triggers reference the selected items.</span>
|
||||
{:else}
|
||||
{#each triggersByKind as [kind, triggers] (kind)}
|
||||
<div class="flex flex-col gap-2 rounded-md border bg-surface-secondary p-3">
|
||||
<div class="flex items-center gap-2 border-b pb-2">
|
||||
<span class="rounded border bg-surface px-1.5 py-0.5 font-mono text-xs text-primary">
|
||||
{TRIGGER_KIND_BADGE[kind]}
|
||||
</span>
|
||||
<span class="text-[11px] text-hint">
|
||||
{triggers.length} trigger{triggers.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
{#if TRIGGER_KIND_NOTE[kind]}
|
||||
<span class="ml-auto">
|
||||
<Popover notClickable placement="top">
|
||||
<Info size={12} class="text-blue-600 dark:text-blue-400" />
|
||||
{#snippet text()}
|
||||
<div
|
||||
class="flex w-72 max-w-[90vw] flex-col gap-1 text-left text-[11px] normal-case"
|
||||
>
|
||||
<span>{TRIGGER_KIND_NOTE[kind]}</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each triggers as t (t.path)}
|
||||
{@const runnableSummary = runnableSummaryByPath.get(
|
||||
`${t.is_flow ? 'flow' : 'script'}:${t.script_path}`
|
||||
)}
|
||||
{@const details = triggerDetails(t)}
|
||||
{@const cfg = t.config as any}
|
||||
{@const previewKey = t.kind === 'schedule' ? `${cfg.schedule}|${cfg.timezone}` : ''}
|
||||
{@const preview = t.kind === 'schedule' ? schedulePreviews[previewKey] : undefined}
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if t.is_flow}
|
||||
<BarsStaggered size={14} class="shrink-0 text-hint" />
|
||||
{:else}
|
||||
<Code2 size={14} class="shrink-0 text-hint" />
|
||||
{/if}
|
||||
<span class="break-all font-mono text-primary">
|
||||
{runnableSummary || t.script_path}
|
||||
</span>
|
||||
<span
|
||||
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"
|
||||
>
|
||||
{t.is_flow ? 'flow' : 'script'}
|
||||
</span>
|
||||
</div>
|
||||
{#if details.length > 0}
|
||||
<dl class="ml-5 grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 text-[11px]">
|
||||
{#each details as d (d.label)}
|
||||
<dt class="text-hint">{d.label}</dt>
|
||||
<dd class="break-all font-mono text-tertiary">{d.value}</dd>
|
||||
{/each}
|
||||
{#if t.kind === 'schedule'}
|
||||
<dt class="text-hint">Next runs</dt>
|
||||
<dd class="text-tertiary">
|
||||
{#if preview && preview.length > 0}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each preview as date (date)}
|
||||
<span>{dateFmt.format(new Date(date))}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if preview && preview.length === 0}
|
||||
<span class="text-hint">No upcoming run</span>
|
||||
{:else}
|
||||
<span class="text-hint">Loading…</span>
|
||||
{/if}
|
||||
</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer bind:this={bundleDrawer} size="600px">
|
||||
<DrawerContent title="Bundle to Hub" on:close={() => bundleDrawer?.closeDrawer()}>
|
||||
<div class="flex flex-col gap-4">
|
||||
@@ -1558,6 +1985,24 @@
|
||||
<span class="font-semibold text-primary">Name</span>
|
||||
<TextInput bind:value={bundleName} inputProps={{ placeholder: 'e.g. Acme CRM toolkit' }} />
|
||||
</label>
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-semibold text-primary">Project slug</span>
|
||||
<span class="rounded border bg-surface-secondary px-2 py-1.5 font-mono text-secondary">
|
||||
{effectiveSlug || sanitizeSlug(bundleName) || '—'}
|
||||
</span>
|
||||
<span class="text-[11px] text-hint">
|
||||
{#if effectiveSlug}
|
||||
Locked — items live under <span class="font-mono">f/{effectiveSlug}/</span>.
|
||||
{:else if bundleName.trim() && !isValidSlug(sanitizeSlug(bundleName))}
|
||||
<span class="text-red-600 dark:text-red-400">
|
||||
The name yields an invalid slug. Use at least 3 letters/digits.
|
||||
</span>
|
||||
{:else}
|
||||
Auto-generated from the name. Once project forked, items will live under
|
||||
<span class="font-mono">f/{sanitizeSlug(bundleName) || '<slug>'}/</span>.
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-semibold text-primary">Summary</span>
|
||||
<TextInput
|
||||
@@ -1582,7 +2027,7 @@
|
||||
<Button
|
||||
variant="accent"
|
||||
loading={deploying}
|
||||
disabled={!bundleName.trim()}
|
||||
disabled={!bundleName.trim() || (!effectiveSlug && !isValidSlug(sanitizeSlug(bundleName)))}
|
||||
startIcon={{ icon: Cloud }}
|
||||
onclick={confirmBundle}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Pure logic for the "project = folder" Hub bundle (Stage 1).
|
||||
// Pure logic for the "project = folder" Hub bundle.
|
||||
//
|
||||
// A project is a single folder `f/<slug>/...`. Bundling it means:
|
||||
// 1. collect the transitive closure of what the folder references,
|
||||
@@ -27,14 +27,12 @@ export type PathClass = 'internal' | 'hub' | 'external'
|
||||
/** A single `$res:PATH` / `res://PATH` token (path captured in group 1). */
|
||||
const RES_TOKEN_RE = /(?:\$res:|res:\/\/)([\w\-./]+)/g
|
||||
|
||||
/** Classify a path relative to the project folder. */
|
||||
export function classifyPath(path: string, slug: string): PathClass {
|
||||
if (path.startsWith(`f/${slug}/`) || path === `f/${slug}`) return 'internal'
|
||||
if (path.startsWith('hub/')) return 'hub'
|
||||
return 'external'
|
||||
}
|
||||
|
||||
/** Resource references inside a script's code. */
|
||||
export function extractScriptRefs(content: string): Ref[] {
|
||||
const out: Ref[] = []
|
||||
const seen = new Set<string>()
|
||||
@@ -94,7 +92,6 @@ export function extractFlowRefs(value: any): Ref[] {
|
||||
return out
|
||||
}
|
||||
|
||||
/** References inside an app value (resources only). */
|
||||
export function extractAppRefs(value: any): Ref[] {
|
||||
return extractScriptRefs(JSON.stringify(value ?? {}))
|
||||
}
|
||||
@@ -120,7 +117,7 @@ export function buildPathMap(externalPaths: Iterable<string>, slug: string): Map
|
||||
return map
|
||||
}
|
||||
|
||||
/** Rewrite `$res:` / `res://` tokens in code using the map (normalized to `$res:`). */
|
||||
// Both ref forms normalize to `$res:` on rewrite.
|
||||
export function rewriteContent(content: string, map: Map<string, string>): string {
|
||||
return content.replace(RES_TOKEN_RE, (whole, path) => {
|
||||
const next = map.get(path)
|
||||
@@ -128,7 +125,6 @@ export function rewriteContent(content: string, map: Map<string, string>): strin
|
||||
})
|
||||
}
|
||||
|
||||
/** Deep-rewrite a flow value: inline code, static `$res:` inputs, and script paths. */
|
||||
export function rewriteFlowValue(value: any, map: Map<string, string>): any {
|
||||
const cloned = JSON.parse(JSON.stringify(value ?? {}))
|
||||
const visitModules = (modules: any[]) => {
|
||||
@@ -160,17 +156,13 @@ export function rewriteFlowValue(value: any, map: Map<string, string>): any {
|
||||
return cloned
|
||||
}
|
||||
|
||||
/** Rewrite resource tokens inside an app value (round-trips via JSON). */
|
||||
// Round-trips through JSON since app values are opaque.
|
||||
export function rewriteAppValue(value: any, map: Map<string, string>): any {
|
||||
if (value == null) return value
|
||||
const json = JSON.stringify(value)
|
||||
return JSON.parse(rewriteContent(json, map))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Closure orchestrator (Stage 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ItemKind = 'script' | 'flow' | 'app' | 'raw_app'
|
||||
|
||||
export interface ItemRef {
|
||||
@@ -178,7 +170,6 @@ export interface ItemRef {
|
||||
path: string
|
||||
}
|
||||
|
||||
/** Everything needed to extract refs from, rewrite, and later push an item. */
|
||||
export interface FetchedItem {
|
||||
kind: ItemKind
|
||||
path: string
|
||||
@@ -228,15 +219,8 @@ function refsForFetched(item: FetchedItem): Ref[] {
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a self-contained project bundle from a set of seed items.
|
||||
*
|
||||
* Walks the transitive closure of references: scripts referenced by path are
|
||||
* pulled in as items (recursively), resources are pulled in as empty stubs.
|
||||
* `hub/...` references are external dependencies and left untouched. Every
|
||||
* collected path (items + resources) is relocated under `f/<slug>/...` and all
|
||||
* references are rewritten to the new paths.
|
||||
*/
|
||||
// Walks the transitive closure: scripts referenced by path are pulled in
|
||||
// recursively, resources become empty stubs, hub refs stay external.
|
||||
export async function buildProjectBundle(
|
||||
seed: ItemRef[],
|
||||
slug: string,
|
||||
|
||||
Reference in New Issue
Block a user