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:
tristantr
2026-05-25 12:03:13 +02:00
co-authored by Claude Opus 4.7
parent 31d94e8d3a
commit dd416cf519
3 changed files with 444 additions and 340 deletions
@@ -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 />