mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-12 16:05:43 +00:00
Add publish-app drawer with per-app rate limit mock
- Publish drawer on raw_apps/apps exposes public URL, copy-iframe, unpublish - Inline per-app rate limit config (req/min, burst, per-IP toggle) - Rename workspace settings "Default app" tab header to "Apps" to cover both default app and public rate limiting 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
31d94e8d3a
commit
dd416cf519
@@ -21,6 +21,7 @@
|
||||
deploymentStatus: Record<string, { status: 'loading' | 'deployed' | 'failed'; error?: string }>
|
||||
allSelected?: boolean
|
||||
emptyMessage?: string
|
||||
hideSelection?: boolean
|
||||
children?: Snippet
|
||||
|
||||
// Snippets for customization
|
||||
@@ -43,6 +44,7 @@
|
||||
deploymentStatus,
|
||||
allSelected = false,
|
||||
emptyMessage = 'No items to deploy',
|
||||
hideSelection = false,
|
||||
header,
|
||||
alerts,
|
||||
itemSummary,
|
||||
@@ -72,20 +74,22 @@
|
||||
|
||||
{#if items.length > 0}
|
||||
<!-- Select all row -->
|
||||
<div class="px-4 py-2 flex items-center justify-between">
|
||||
<div
|
||||
class="flex items-center gap-2 text-secondary text-xs"
|
||||
class:opacity-50={!hasSelectableItems}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!hasSelectableItems}
|
||||
checked={allSelected}
|
||||
onchange={allSelected ? onDeselectAll : onSelectAll}
|
||||
class="rounded max-w-4 w-full"
|
||||
/> Select all
|
||||
{#if !hideSelection}
|
||||
<div class="px-4 py-2 flex items-center justify-between">
|
||||
<div
|
||||
class="flex items-center gap-2 text-secondary text-xs"
|
||||
class:opacity-50={!hasSelectableItems}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!hasSelectableItems}
|
||||
checked={allSelected}
|
||||
onchange={allSelected ? onDeselectAll : onSelectAll}
|
||||
class="rounded max-w-4 w-full"
|
||||
/> Select all
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Items list -->
|
||||
<div class="overflow-y-auto">
|
||||
@@ -97,9 +101,9 @@
|
||||
{@const isDeployed = status?.status === 'deployed'}
|
||||
|
||||
<Row
|
||||
isSelectable={isSelectable && !isDeployed}
|
||||
alignWithSelectable={true}
|
||||
disabled={!isSelectable}
|
||||
isSelectable={!hideSelection && isSelectable && !isDeployed}
|
||||
alignWithSelectable={!hideSelection}
|
||||
disabled={!hideSelection && !isSelectable}
|
||||
selected={isSelected && !isDeployed}
|
||||
onSelect={() => onToggleItem?.(item)}
|
||||
path={item.kind !== 'resource' &&
|
||||
|
||||
@@ -1,377 +1,384 @@
|
||||
<script lang="ts">
|
||||
import { Button, Drawer, DrawerContent } from '$lib/components/common'
|
||||
// NOTE: mocked preview for a team demo. No backend calls.
|
||||
// Push/override model: a single "Deploy to Hub" action bundles the whole workspace
|
||||
// and republishes it as a new version on the Hub. No per-item diff / merge / drift.
|
||||
// Recording per script/flow is kept as an orthogonal feature attached to the
|
||||
// current published version.
|
||||
import { Badge, Button, Drawer, DrawerContent } from '$lib/components/common'
|
||||
import WorkspaceDeployLayout from '$lib/components/WorkspaceDeployLayout.svelte'
|
||||
import SchemaForm from '$lib/components/SchemaForm.svelte'
|
||||
import IconedResourceType from '$lib/components/IconedResourceType.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { AppService, FlowService, RawAppService, ResourceService, ScriptService } from '$lib/gen'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import {
|
||||
AppWindow,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleSlash,
|
||||
Database,
|
||||
Cloud,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FileCode2,
|
||||
Layout,
|
||||
Loader2,
|
||||
Globe,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Workflow
|
||||
TriangleAlert
|
||||
} from 'lucide-svelte'
|
||||
import { onMount } from 'svelte'
|
||||
import type { Kind } from '$lib/utils_deployable'
|
||||
|
||||
type Bucket = 'script' | 'flow' | 'app' | 'raw_app' | 'resource'
|
||||
|
||||
interface Item {
|
||||
type Phase = 'predeploy' | 'live'
|
||||
type RecStatus = 'none' | 'recording' | 'recorded'
|
||||
interface DeployItem {
|
||||
key: string
|
||||
path: string
|
||||
kind: Bucket
|
||||
kind: Kind
|
||||
summary?: string
|
||||
resourceType?: string
|
||||
rec: RecStatus
|
||||
published?: boolean
|
||||
publicUrl?: string
|
||||
rateLimit?: RateLimitConfig
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
// Which buckets need a per-item artifact before deploy.
|
||||
// script/flow → execution recording, app/raw_app → built bundle.
|
||||
type Artifact = 'recording' | 'bundle'
|
||||
function artifactOf(kind: Bucket): Artifact | undefined {
|
||||
if (kind === 'script' || kind === 'flow') return 'recording'
|
||||
if (kind === 'app' || kind === 'raw_app') return 'bundle'
|
||||
return undefined
|
||||
interface RateLimitConfig {
|
||||
enabled: boolean
|
||||
perMinute: number
|
||||
burst: number
|
||||
perIp: boolean
|
||||
}
|
||||
|
||||
type ItemStatus = 'idle' | 'running' | 'done' | 'error'
|
||||
interface ItemState {
|
||||
status: ItemStatus
|
||||
durationMs?: number
|
||||
const DEFAULT_RATE_LIMIT: RateLimitConfig = {
|
||||
enabled: true,
|
||||
perMinute: 60,
|
||||
burst: 10,
|
||||
perIp: true
|
||||
}
|
||||
const WORKSPACE_DEFAULT_RATE_LIMIT = { perMinute: 120, burst: 20 }
|
||||
|
||||
const BUCKET_ORDER: Bucket[] = ['app', 'raw_app', 'flow', 'script', 'resource']
|
||||
const BUCKET_LABEL: Record<Bucket, string> = {
|
||||
app: 'Apps',
|
||||
raw_app: 'Raw apps',
|
||||
flow: 'Flows',
|
||||
script: 'Scripts',
|
||||
resource: 'Resource types'
|
||||
const canRecord = (k: Kind) => k === 'script' || k === 'flow'
|
||||
const canPublishApp = (k: Kind) => k === 'app' || k === 'raw_app'
|
||||
|
||||
// --- MOCK DATA -------------------------------------------------------------
|
||||
let items = $state<DeployItem[]>([
|
||||
{
|
||||
key: 'raw_app:crm/dashboard',
|
||||
path: 'crm/dashboard',
|
||||
kind: 'raw_app',
|
||||
summary: 'Sales dashboard',
|
||||
rec: 'none'
|
||||
},
|
||||
{
|
||||
key: 'raw_app:crm/onboarding',
|
||||
path: 'crm/onboarding',
|
||||
kind: 'raw_app',
|
||||
summary: 'Customer onboarding portal',
|
||||
rec: 'none'
|
||||
},
|
||||
{
|
||||
key: 'flow:crm/sync_contacts',
|
||||
path: 'crm/sync_contacts',
|
||||
kind: 'flow',
|
||||
summary: 'Sync contacts from HubSpot',
|
||||
rec: 'none'
|
||||
},
|
||||
{
|
||||
key: 'flow:crm/enrich_lead',
|
||||
path: 'crm/enrich_lead',
|
||||
kind: 'flow',
|
||||
summary: 'Enrich lead with Clearbit',
|
||||
rec: 'none'
|
||||
},
|
||||
{
|
||||
key: 'script:crm/send_slack_digest',
|
||||
path: 'crm/send_slack_digest',
|
||||
kind: 'script',
|
||||
summary: 'Daily Slack digest',
|
||||
rec: 'none'
|
||||
},
|
||||
{
|
||||
key: 'script:crm/upsert_postgres',
|
||||
path: 'crm/upsert_postgres',
|
||||
kind: 'script',
|
||||
summary: 'Upsert rows to Postgres',
|
||||
rec: 'none'
|
||||
},
|
||||
{
|
||||
key: 'resource:postgresql',
|
||||
path: 'postgresql',
|
||||
kind: 'resource',
|
||||
resourceType: 'postgresql',
|
||||
rec: 'none'
|
||||
},
|
||||
{
|
||||
key: 'resource:slack_bot',
|
||||
path: 'slack_bot',
|
||||
kind: 'resource',
|
||||
resourceType: 'slack_bot',
|
||||
rec: 'none'
|
||||
}
|
||||
])
|
||||
const FAKE_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
customer: { type: 'string', description: 'Customer to run against' },
|
||||
includeArchived: { type: 'boolean', description: 'Include archived rows', default: false }
|
||||
},
|
||||
required: ['customer']
|
||||
}
|
||||
const BUCKET_ICON = {
|
||||
app: AppWindow,
|
||||
raw_app: Layout,
|
||||
flow: Workflow,
|
||||
script: FileCode2,
|
||||
resource: Database
|
||||
} as const
|
||||
const hubSlug = 'twenty-crm'
|
||||
const hubUrl = `https://hub.windmill.dev/workspaces/${hubSlug}`
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let items = $state<Item[]>([])
|
||||
let loadingItems = $state(false)
|
||||
let collapsed = $state<Partial<Record<Bucket, boolean>>>({})
|
||||
let states = $state<Record<string, ItemState>>({})
|
||||
let phase = $state<Phase>('predeploy')
|
||||
let hubVersion = $state<number>(0)
|
||||
let deploymentStatus = $state<
|
||||
Record<string, { status: 'loading' | 'deployed' | 'failed'; error?: string }>
|
||||
>({})
|
||||
let deploying = $state(false)
|
||||
|
||||
// Recording input drawer: clicking Record on a script/flow opens a form built
|
||||
// from the item's JSON schema so the user provides the args used for the run.
|
||||
let recordDrawer = $state<Drawer | undefined>()
|
||||
let recordTarget = $state<Item | undefined>()
|
||||
let recordSchema = $state<Record<string, any> | undefined>()
|
||||
let recordTarget = $state<DeployItem | undefined>()
|
||||
let recordArgs = $state<Record<string, any>>({})
|
||||
let recordValid = $state(true)
|
||||
let loadingSchema = $state(false)
|
||||
|
||||
function idOf(it: Item): string {
|
||||
return it.kind + ':' + it.path
|
||||
}
|
||||
let publishDrawer = $state<Drawer | undefined>()
|
||||
let publishTarget = $state<DeployItem | undefined>()
|
||||
let publishing = $state(false)
|
||||
let publishRateLimit = $state<RateLimitConfig>({ ...DEFAULT_RATE_LIMIT })
|
||||
|
||||
async function loadItems() {
|
||||
if (!$workspaceStore) return
|
||||
loadingItems = true
|
||||
const workspace = $workspaceStore
|
||||
try {
|
||||
const [scripts, flows, apps, rawApps, resources] = await Promise.all([
|
||||
ScriptService.listScripts({ workspace }),
|
||||
FlowService.listFlows({ workspace }),
|
||||
AppService.listApps({ workspace }),
|
||||
RawAppService.listRawApps({ workspace }),
|
||||
ResourceService.listResource({ workspace })
|
||||
])
|
||||
items = [
|
||||
...apps.map((a) => ({ path: a.path, kind: 'app' as const, summary: a.summary })),
|
||||
...rawApps.map((a) => ({ path: a.path, kind: 'raw_app' as const, summary: a.summary })),
|
||||
...flows.map((f) => ({ path: f.path, kind: 'flow' as const, summary: f.summary })),
|
||||
...scripts.map((s) => ({ path: s.path, kind: 'script' as const, summary: s.summary })),
|
||||
// Only known/shared resource types: dedupe the resource_type of each resource,
|
||||
// excluding the auto-created app_theme and workspace-local custom types (`c_*`).
|
||||
...[
|
||||
...new Set(
|
||||
resources
|
||||
.map((r) => r.resource_type)
|
||||
.filter((rt): rt is string => !!rt && rt !== 'app_theme' && !rt.startsWith('c_'))
|
||||
)
|
||||
].map((rt) => ({ path: rt, kind: 'resource' as const, resourceType: rt }))
|
||||
]
|
||||
} finally {
|
||||
loadingItems = false
|
||||
}
|
||||
}
|
||||
const mockPublicUrl = (path: string) => `https://app.windmill.dev/public/${hubSlug}/${path}`
|
||||
|
||||
let grouped = $derived(
|
||||
BUCKET_ORDER.map((b) => ({ bucket: b, list: items.filter((i) => i.kind === b) })).filter(
|
||||
(g) => g.list.length > 0
|
||||
)
|
||||
)
|
||||
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
// Items that require an artifact (recording or bundle) before deploy.
|
||||
let actionable = $derived(items.filter((i) => artifactOf(i.kind) !== undefined))
|
||||
let doneCount = $derived(actionable.filter((i) => states[idOf(i)]?.status === 'done').length)
|
||||
let allDone = $derived(actionable.length > 0 && doneCount === actionable.length)
|
||||
|
||||
function statusOf(it: Item): ItemState {
|
||||
return states[idOf(it)] ?? { status: 'idle' }
|
||||
}
|
||||
|
||||
function toggle(b: Bucket) {
|
||||
collapsed = { ...collapsed, [b]: !collapsed[b] }
|
||||
}
|
||||
|
||||
// MOCK: simulate running the script/flow with `args` (recording) or building the
|
||||
// app bundle. Real impl will drive the recording stores + JobLoader.
|
||||
async function runItem(it: Item, _args: Record<string, any> = {}): Promise<void> {
|
||||
const id = idOf(it)
|
||||
states = { ...states, [id]: { status: 'running' } }
|
||||
const ms = 700 + Math.round(Math.random() * 1500)
|
||||
await new Promise((r) => setTimeout(r, ms))
|
||||
// MOCK: ~10% failure to show the error state.
|
||||
const failed = Math.random() < 0.1
|
||||
states = {
|
||||
...states,
|
||||
[id]: failed ? { status: 'error' } : { status: 'done', durationMs: ms }
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the JSON schema of a script/flow so the input form can be rendered.
|
||||
async function fetchSchema(it: Item): Promise<Record<string, any> | undefined> {
|
||||
if (!$workspaceStore) return undefined
|
||||
const workspace = $workspaceStore
|
||||
if (it.kind === 'script') {
|
||||
const s = await ScriptService.getScriptByPath({ workspace, path: it.path })
|
||||
return s.schema as Record<string, any> | undefined
|
||||
}
|
||||
if (it.kind === 'flow') {
|
||||
const f = await FlowService.getFlowByPath({ workspace, path: it.path })
|
||||
return f.schema as Record<string, any> | undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Entry point for the per-item action button.
|
||||
async function startItem(it: Item): Promise<void> {
|
||||
const artifact = artifactOf(it.kind)
|
||||
if (artifact === 'bundle') {
|
||||
// Apps need no inputs — build directly.
|
||||
await runItem(it)
|
||||
return
|
||||
}
|
||||
// Recording: collect inputs first via the drawer.
|
||||
recordTarget = it
|
||||
recordArgs = {}
|
||||
recordValid = true
|
||||
recordSchema = undefined
|
||||
recordDrawer?.openDrawer()
|
||||
loadingSchema = true
|
||||
try {
|
||||
recordSchema = await fetchSchema(it)
|
||||
} finally {
|
||||
loadingSchema = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRecord(): Promise<void> {
|
||||
const it = recordTarget
|
||||
if (!it) return
|
||||
recordDrawer?.closeDrawer()
|
||||
await runItem(it, recordArgs)
|
||||
}
|
||||
|
||||
function cancelRecord(): void {
|
||||
recordDrawer?.closeDrawer()
|
||||
}
|
||||
|
||||
async function deployToHub() {
|
||||
async function deployAll() {
|
||||
deploying = true
|
||||
try {
|
||||
// TODO: bundle (tarball export + recordings + app bundles + resource types)
|
||||
// and POST to the Hub.
|
||||
await new Promise((r) => setTimeout(r, 600))
|
||||
sendUserToast(`Submitted ${items.length} item(s) to the Hub for review`)
|
||||
for (const it of items) {
|
||||
deploymentStatus = { ...deploymentStatus, [it.key]: { status: 'loading' } }
|
||||
await delay(120)
|
||||
deploymentStatus = { ...deploymentStatus, [it.key]: { status: 'deployed' } }
|
||||
}
|
||||
await delay(150)
|
||||
hubVersion += 1
|
||||
// Reset recordings on republish: they were tied to the previous version.
|
||||
if (phase === 'live') {
|
||||
items = items.map((i) => ({ ...i, rec: i.rec === 'recorded' ? 'none' : i.rec }))
|
||||
}
|
||||
deploymentStatus = {}
|
||||
phase = 'live'
|
||||
sendUserToast(
|
||||
hubVersion === 1
|
||||
? `Published to the Hub as v1 (${items.length} items)`
|
||||
: `Republished to the Hub as v${hubVersion}`
|
||||
)
|
||||
} finally {
|
||||
deploying = false
|
||||
}
|
||||
}
|
||||
|
||||
function actionVerb(a: Artifact): string {
|
||||
return a === 'recording' ? 'Record' : 'Build'
|
||||
function openRecord(it: DeployItem) {
|
||||
recordTarget = it
|
||||
recordArgs = {}
|
||||
recordValid = true
|
||||
recordDrawer?.openDrawer()
|
||||
}
|
||||
async function confirmRecord() {
|
||||
const it = recordTarget
|
||||
if (!it) return
|
||||
recordDrawer?.closeDrawer()
|
||||
items = items.map((i) => (i.key === it.key ? { ...i, rec: 'recording' } : i))
|
||||
await delay(900)
|
||||
items = items.map((i) => (i.key === it.key ? { ...i, rec: 'recorded' } : i))
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadItems()
|
||||
})
|
||||
function openPublish(it: DeployItem) {
|
||||
publishTarget = it
|
||||
publishRateLimit = { ...(it.rateLimit ?? DEFAULT_RATE_LIMIT) }
|
||||
publishDrawer?.openDrawer()
|
||||
}
|
||||
async function confirmPublish() {
|
||||
const it = publishTarget
|
||||
if (!it) return
|
||||
publishing = true
|
||||
try {
|
||||
await delay(500)
|
||||
const rl = { ...publishRateLimit }
|
||||
items = items.map((i) =>
|
||||
i.key === it.key
|
||||
? { ...i, published: true, publicUrl: mockPublicUrl(i.path), rateLimit: rl }
|
||||
: i
|
||||
)
|
||||
sendUserToast(
|
||||
rl.enabled
|
||||
? `${it.path} is now public (${rl.perMinute} req/min, burst ${rl.burst})`
|
||||
: `${it.path} is now public (no rate limit)`
|
||||
)
|
||||
publishDrawer?.closeDrawer()
|
||||
} finally {
|
||||
publishing = false
|
||||
}
|
||||
}
|
||||
async function unpublishApp(key: string) {
|
||||
items = items.map((i) => (i.key === key ? { ...i, published: false, publicUrl: undefined } : i))
|
||||
sendUserToast('App unpublished')
|
||||
}
|
||||
async function copyIframeSnippet(url: string) {
|
||||
const snippet = `<iframe src="${url}" width="100%" height="600" frameborder="0"></iframe>`
|
||||
try {
|
||||
await navigator.clipboard.writeText(snippet)
|
||||
sendUserToast('Iframe snippet copied to clipboard')
|
||||
} catch {
|
||||
sendUserToast('Failed to copy snippet', true)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if loadingItems}
|
||||
<div class="flex items-center gap-2 text-secondary text-sm">
|
||||
<Loader2 class="animate-spin" size={16} /> Loading items…
|
||||
</div>
|
||||
{:else if items.length === 0}
|
||||
<p class="text-sm text-secondary">No deployable items found in this workspace.</p>
|
||||
{:else}
|
||||
<div class="border rounded-md bg-surface-tertiary overflow-hidden">
|
||||
<div
|
||||
class="px-3 py-2.5 border-b flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-tertiary"
|
||||
>
|
||||
<span>Files</span>
|
||||
<span class="ml-auto normal-case font-medium text-hint">
|
||||
{doneCount}/{actionable.length} ready
|
||||
</span>
|
||||
</div>
|
||||
<div class="py-2 overflow-y-auto max-h-[calc(100dvh-22rem)]">
|
||||
{#each grouped as { bucket, list } (bucket)}
|
||||
{@const Icon = BUCKET_ICON[bucket]}
|
||||
{@const open = !collapsed[bucket]}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-1.5 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider hover:bg-surface-secondary/60 transition text-tertiary hover:text-secondary"
|
||||
onclick={() => toggle(bucket)}
|
||||
>
|
||||
{#if open}
|
||||
<ChevronDown size={12} class="shrink-0" />
|
||||
{:else}
|
||||
<ChevronRight size={12} class="shrink-0" />
|
||||
{/if}
|
||||
<Icon size={12} class="shrink-0" />
|
||||
<span>{BUCKET_LABEL[bucket]}</span>
|
||||
<span class="ml-auto text-[10px] font-medium normal-case text-hint">{list.length}</span>
|
||||
</button>
|
||||
{#if open}
|
||||
<ul>
|
||||
{#each list as it (it.kind + ':' + it.path)}
|
||||
{@const artifact = artifactOf(it.kind)}
|
||||
{@const st = statusOf(it)}
|
||||
<li
|
||||
class="flex items-center gap-2 pl-8 pr-3 py-1 text-xs text-primary"
|
||||
title={it.path}
|
||||
>
|
||||
{#if it.kind === 'resource'}
|
||||
{#if it.resourceType}
|
||||
<IconedResourceType
|
||||
name={it.resourceType}
|
||||
silent
|
||||
width="16px"
|
||||
height="16px"
|
||||
/>
|
||||
<span class="truncate"
|
||||
>{it.resourceType.charAt(0).toUpperCase() + it.resourceType.slice(1)}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="truncate">{it.path}</span>
|
||||
{/if}
|
||||
<span class="ml-auto flex items-center gap-1 text-[10px] text-hint">
|
||||
<CircleSlash size={11} /> Referenced
|
||||
</span>
|
||||
{:else}
|
||||
<span class="truncate">{it.summary?.trim() || it.path}</span>
|
||||
{#if artifact}
|
||||
<div class="ml-auto flex items-center gap-2 shrink-0">
|
||||
{#if st.status === 'done'}
|
||||
<span class="flex items-center gap-1 text-[10px] text-green-600">
|
||||
<Check size={12} />
|
||||
{artifact === 'recording' ? 'Recorded' : 'Built'}
|
||||
{#if st.durationMs}
|
||||
<span class="text-hint">({(st.durationMs / 1000).toFixed(1)}s)</span>
|
||||
{/if}
|
||||
</span>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
iconOnly
|
||||
startIcon={{ icon: RotateCcw }}
|
||||
onclick={() => startItem(it)}
|
||||
/>
|
||||
{:else if st.status === 'error'}
|
||||
<span class="text-[10px] text-red-500">Failed</span>
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: RotateCcw }}
|
||||
onclick={() => startItem(it)}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
loading={st.status === 'running'}
|
||||
startIcon={{ icon: Play }}
|
||||
onclick={() => startItem(it)}
|
||||
>
|
||||
{actionVerb(artifact)}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div>
|
||||
<WorkspaceDeployLayout
|
||||
{items}
|
||||
selectedItems={[]}
|
||||
{deploymentStatus}
|
||||
hideSelection
|
||||
emptyMessage="No items to publish"
|
||||
>
|
||||
{#snippet header()}
|
||||
<div class="flex flex-col gap-2 w-full pb-4 border-b">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if phase === 'predeploy'}
|
||||
<Badge color="gray" size="xs">Not on the Hub yet</Badge>
|
||||
{:else}
|
||||
<Badge color="transparent" class="font-semibold">
|
||||
<Cloud size={14} class="mr-1" />
|
||||
<span class="text-secondary">on Hub:</span>
|
||||
<span class="text-emphasis">{hubSlug}</span>
|
||||
</Badge>
|
||||
<Badge color="blue" size="xs">v{hubVersion}</Badge>
|
||||
<a
|
||||
href={hubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
<ExternalLink size={12} /> Open in Hub
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
{#if !allDone}
|
||||
<span class="text-[11px] text-hint">
|
||||
Record all scripts/flows and build all apps before submitting.
|
||||
</span>
|
||||
{#snippet itemSummary(item)}
|
||||
{@const it = item as DeployItem}
|
||||
<span class="truncate">
|
||||
{it.kind === 'resource' ? (it.resourceType ?? it.path) : it.summary?.trim() || it.path}
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet itemActions(item)}
|
||||
{@const it = item as DeployItem}
|
||||
{#if phase === 'live' && canRecord(it.kind)}
|
||||
{#if it.rec === 'recorded'}
|
||||
<Badge color="green" size="xs">
|
||||
<Check size={10} class="mr-0.5" />Recorded v{hubVersion}
|
||||
</Badge>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: RotateCcw }}
|
||||
onclick={() => openRecord(it)}
|
||||
>
|
||||
Re-record
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
loading={it.rec === 'recording'}
|
||||
startIcon={{ icon: Play }}
|
||||
onclick={() => openRecord(it)}
|
||||
>
|
||||
Add recording
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
<Button
|
||||
variant="accent"
|
||||
loading={deploying}
|
||||
disabled={!allDone}
|
||||
startIcon={{ icon: ExternalLink }}
|
||||
onclick={deployToHub}
|
||||
>
|
||||
Submit to Hub ({items.length})
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if phase === 'live' && canPublishApp(it.kind)}
|
||||
{#if it.published && it.publicUrl}
|
||||
<Badge color="green" size="xs">
|
||||
<Globe size={10} class="mr-0.5" />Public
|
||||
</Badge>
|
||||
<a
|
||||
href={it.publicUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
<ExternalLink size={12} /> Open
|
||||
</a>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Copy }}
|
||||
onclick={() => copyIframeSnippet(it.publicUrl!)}
|
||||
>
|
||||
Copy iframe
|
||||
</Button>
|
||||
<Button size="xs" variant="subtle" onclick={() => unpublishApp(it.key)}>Unpublish</Button>
|
||||
{:else}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Globe }}
|
||||
onclick={() => openPublish(it)}
|
||||
>
|
||||
Publish publicly
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
{#if phase === 'predeploy'}
|
||||
<span class="text-[11px] text-hint">
|
||||
Bundles the whole workspace and publishes it to the Hub as v1.
|
||||
</span>
|
||||
<Button
|
||||
variant="accent"
|
||||
loading={deploying}
|
||||
startIcon={{ icon: Cloud }}
|
||||
onclick={deployAll}
|
||||
>
|
||||
Deploy to Hub ({items.length})
|
||||
</Button>
|
||||
{:else}
|
||||
<span class="text-[11px] text-hint">
|
||||
Republishes the current workspace state as v{hubVersion + 1}, overriding the Hub copy.
|
||||
</span>
|
||||
<Button
|
||||
variant="accent"
|
||||
loading={deploying}
|
||||
startIcon={{ icon: Cloud }}
|
||||
onclick={deployAll}
|
||||
>
|
||||
Update Hub (v{hubVersion} → v{hubVersion + 1})
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</WorkspaceDeployLayout>
|
||||
</div>
|
||||
|
||||
<Drawer bind:this={recordDrawer} size="600px" on:close={cancelRecord}>
|
||||
<Drawer bind:this={recordDrawer} size="600px">
|
||||
<DrawerContent
|
||||
title={recordTarget ? `Record — ${recordTarget.path}` : 'Record'}
|
||||
on:close={cancelRecord}
|
||||
on:close={() => recordDrawer?.closeDrawer()}
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-xs text-secondary">
|
||||
Provide the inputs to run this {recordTarget?.kind} once. The run is captured as a recording
|
||||
and shipped with the workspace to the Hub.
|
||||
and attached to the item's current version on the Hub.
|
||||
</p>
|
||||
{#if loadingSchema}
|
||||
<div class="flex items-center gap-2 text-secondary text-sm">
|
||||
<Loader2 class="animate-spin" size={16} /> Loading inputs…
|
||||
</div>
|
||||
{:else if recordSchema}
|
||||
<SchemaForm bind:args={recordArgs} bind:isValid={recordValid} schema={recordSchema} />
|
||||
{:else}
|
||||
<p class="text-sm text-secondary">This item has no inputs.</p>
|
||||
{/if}
|
||||
<SchemaForm bind:args={recordArgs} bind:isValid={recordValid} schema={FAKE_SCHEMA} />
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button variant="default" onclick={cancelRecord}>Cancel</Button>
|
||||
<Button variant="default" onclick={() => recordDrawer?.closeDrawer()}>Cancel</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={loadingSchema || !recordValid}
|
||||
disabled={!recordValid}
|
||||
startIcon={{ icon: Play }}
|
||||
onclick={confirmRecord}
|
||||
>
|
||||
@@ -380,3 +387,96 @@
|
||||
{/snippet}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer bind:this={publishDrawer} size="600px">
|
||||
<DrawerContent
|
||||
title={publishTarget ? `Publish — ${publishTarget.path}` : 'Publish'}
|
||||
on:close={() => publishDrawer?.closeDrawer()}
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-xs text-secondary">
|
||||
This will expose <span class="font-mono text-emphasis">{publishTarget?.path}</span> at a public
|
||||
URL so the Hub can embed it as a live iframe instead of a frontend-only run. Anyone with the
|
||||
URL will be able to interact with it.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-2 rounded-md border bg-surface-secondary p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<TriangleAlert size={14} class="text-orange-600" />
|
||||
<span class="text-sm font-semibold">Rate limit</span>
|
||||
<Tooltip>
|
||||
Caps requests to this public app. Workspace default: {WORKSPACE_DEFAULT_RATE_LIMIT.perMinute}
|
||||
req/min, burst {WORKSPACE_DEFAULT_RATE_LIMIT.burst}. Override here for this app only.
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Toggle bind:checked={publishRateLimit.enabled} size="xs" />
|
||||
</div>
|
||||
{#if publishRateLimit.enabled}
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-secondary">Requests / minute</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={publishRateLimit.perMinute}
|
||||
class="rounded border px-2 py-1 text-xs"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-secondary">Burst</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={publishRateLimit.burst}
|
||||
class="rounded border px-2 py-1 text-xs"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="flex items-center gap-1 text-xs text-secondary">
|
||||
Apply per client IP
|
||||
<Tooltip>If off, limit is shared across all callers (global counter).</Tooltip>
|
||||
</span>
|
||||
<Toggle bind:checked={publishRateLimit.perIp} size="xs" />
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-xs text-orange-700">
|
||||
Disabled — anyone with the URL can hit this app at any rate.
|
||||
</span>
|
||||
{/if}
|
||||
<span class="text-[11px] text-hint">
|
||||
Workspace-wide defaults live in <a
|
||||
href="#default_app"
|
||||
class="underline"
|
||||
onclick={(e) => {
|
||||
e.preventDefault()
|
||||
publishDrawer?.closeDrawer()
|
||||
window.location.hash = 'default_app'
|
||||
}}>Workspace settings → Default app → Rate limiting</a
|
||||
>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if publishTarget}
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-secondary">Public URL once published:</span>
|
||||
<code class="rounded bg-surface-secondary p-2 break-all">
|
||||
{mockPublicUrl(publishTarget.path)}
|
||||
</code>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button variant="default" onclick={() => publishDrawer?.closeDrawer()}>Cancel</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
loading={publishing}
|
||||
startIcon={{ icon: Globe }}
|
||||
onclick={confirmPublish}
|
||||
>
|
||||
Publish publicly
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@@ -1103,9 +1103,9 @@
|
||||
},
|
||||
{
|
||||
id: 'hub',
|
||||
label: 'Submit to Hub',
|
||||
label: 'Deploy to Hub',
|
||||
aiId: 'workspace-settings-hub',
|
||||
aiDescription: 'Submit workspace folders to the Hub for review'
|
||||
aiDescription: 'Publish this workspace to the Hub'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1874,8 +1874,8 @@ export async function main(
|
||||
<WorkspaceDependenciesSettings />
|
||||
{:else if tab == 'default_app'}
|
||||
<SettingsPageHeader
|
||||
title="Workspace default app"
|
||||
description="If configured, users who are operators in this workspace will be redirected to this app automatically when logging into this workspace. Make sure the default app is shared with all the operators of this workspace before turning this feature on."
|
||||
title="Apps"
|
||||
description="Workspace-level settings for apps: default app for operators, and rate limiting for public (anonymous) app executions."
|
||||
link="https://www.windmill.dev/docs/apps/default_app"
|
||||
/>
|
||||
{#if !$enterpriseLicense}
|
||||
@@ -1964,8 +1964,8 @@ export async function main(
|
||||
/>
|
||||
{:else if tab == 'hub'}
|
||||
<SettingsPageHeader
|
||||
title="Submit to Hub"
|
||||
description="Bundle all scripts, flows and apps under a folder and submit them to the Hub for review as a workspace."
|
||||
title="Deploy to Hub"
|
||||
description="Publish this workspace to the Hub. Each deploy bundles every script, flow, app and resource and pushes them as a new version."
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<DeployToHub />
|
||||
|
||||
Reference in New Issue
Block a user