feat: mount session list pages in process instead of iframes (#11289)

* feat: mount session list pages in process instead of iframes

* fix: seed filter defaults from the query, not the cached search params

* fix: type trigger list rows from the generated trigger types

* fix: judge hosted lists by the operating workspace's rules and keep role-gated filters

* fix: keep the user folders filter key for every role, hidden where it does not apply

* fix: drop the user folders filter for users it is not offered to

* fix: wait for a known user before dropping the user folders filter

* style: tint trigger rows for every kind and align the schedules footer wording

* fix: stamp edited_at when a schedule is updated

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Diego Imbert
2026-09-24 15:14:59 +02:00
committed by GitHub
co-authored by Ruben Fiszel
parent d02ff9ac24
commit ebb3048ca0
58 changed files with 5057 additions and 9764 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n permissioned_as = $26,\n labels = COALESCE($27, labels)\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ",
"query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n edited_at = now(),\n permissioned_as = $26,\n labels = COALESCE($27, labels)\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ",
"describe": {
"columns": [
{
@@ -236,5 +236,5 @@
true
]
},
"hash": "bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e"
"hash": "4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56"
}
+1
View File
@@ -619,6 +619,7 @@ async fn edit_schedule(
dynamic_skip = $23,
email = $24,
edited_by = $25,
edited_at = now(),
permissioned_as = $26,
labels = COALESCE($27, labels)
WHERE path = $19 AND workspace_id = $20
@@ -1,7 +1,6 @@
<script lang="ts">
import { WorkspaceService } from '$lib/gen'
import { createEventDispatcher } from 'svelte'
import { workspaceStore } from '$lib/stores'
import { Button } from './common'
import Drawer from './common/drawer/Drawer.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
@@ -10,6 +9,9 @@
import autosize from '$lib/autosize'
import Label from './Label.svelte'
import TextInput from './text_input/TextInput.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const dispatch = createEventDispatcher()
@@ -35,7 +37,7 @@
async function updateVariable(): Promise<void> {
await WorkspaceService.setEnvironmentVariable({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
requestBody: {
value: value,
name: name
@@ -6,7 +6,7 @@
collectTransformRefs
} from './deployDependencies'
import { base } from '$lib/base'
import { enterpriseLicense, superadmin, workspaceStore } from '$lib/stores'
import { enterpriseLicense, superadmin } from '$lib/stores'
import {
AppService,
FlowService,
@@ -50,6 +50,9 @@
type OnBehalfOfDetails
} from './OnBehalfOfSelector.svelte'
import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const dispatch = createEventDispatcher()
@@ -209,7 +212,12 @@
let source: string | undefined
let targetValue: string | undefined
try {
source = await getOnBehalfOf(dep.kind, dep.path, $workspaceStore!, additionalInformation)
source = await getOnBehalfOf(
dep.kind,
dep.path,
$operatingWorkspace!,
additionalInformation
)
} catch {}
try {
targetValue = await getOnBehalfOf(dep.kind, dep.path, target!, additionalInformation)
@@ -226,7 +234,10 @@
): Promise<{ kind: Kind; path: string }[]> {
async function rec(kind: Kind, path: string): Promise<{ kind: Kind; path: string }[]> {
if (kind == 'schedule') {
const schedule = await ScheduleService.getSchedule({ workspace: $workspaceStore!, path })
const schedule = await ScheduleService.getSchedule({
workspace: $operatingWorkspace!,
path
})
if (schedule.script_path && schedule.script_path != '') {
if (schedule.script_path) {
return [{ kind: 'script', path: schedule.script_path }]
@@ -237,7 +248,7 @@
return []
}
} else if (kind == 'flow') {
const flow = await FlowService.getFlowByPath({ workspace: $workspaceStore!, path })
const flow = await FlowService.getFlowByPath({ workspace: $operatingWorkspace!, path })
return getAllModules(flow.value.modules, flow.value.failure_module).flatMap((x) => {
let result: { kind: Kind; path: string }[] = []
if (x.value.type == 'script' || x.value.type == 'rawscript' || x.value.type == 'flow') {
@@ -257,7 +268,7 @@
return result
})
} else if (kind == 'app') {
const app = await AppService.getAppByPath({ workspace: $workspaceStore!, path })
const app = await AppService.getAppByPath({ workspace: $operatingWorkspace!, path })
let result: { kind: Kind; path: string }[] = []
if (app.raw_app) {
const rawAppValue = app.value as { runnables?: Record<string, Runnable> }
@@ -285,7 +296,7 @@
}
return result
} else if (kind == 'resource') {
const res = await ResourceService.getResource({ workspace: $workspaceStore!, path })
const res = await ResourceService.getResource({ workspace: $operatingWorkspace!, path })
function recObj(obj: any): { kind: Kind; path: string }[] {
if (typeof obj == 'string') {
if (obj.startsWith('$var:')) {
@@ -310,12 +321,16 @@
]
} else if (kind == 'trigger') {
if (additionalInformation?.triggers) {
return getTriggerDependency(additionalInformation.triggers.kind, path, $workspaceStore!)
return getTriggerDependency(
additionalInformation.triggers.kind,
path,
$operatingWorkspace!
)
}
throw new Error('Missing trigger information')
} else if (kind == 'script') {
const imports = await WorkspaceService.getImports({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
importerPath: path
})
return imports.map((importedPath) => ({ kind: 'script' as Kind, path: importedPath }))
@@ -363,7 +378,7 @@
const result = await deployItem({
kind,
path,
workspaceFrom: $workspaceStore!,
workspaceFrom: $operatingWorkspace!,
workspaceTo: target!,
additionalInformation,
onBehalfOf: getOnBehalfOfForDeploy(statusPath, kind),
@@ -398,7 +413,7 @@
diffDrawer?.openDrawer()
let values = await Promise.all([
getItemValue(kind, path, workspaceToDeployTo!, additionalInformation),
getItemValue(kind, path, $workspaceStore!, additionalInformation)
getItemValue(kind, path, $operatingWorkspace!, additionalInformation)
])
diffDrawer?.setDiff({
mode: 'simple',
@@ -409,12 +424,12 @@
}
async function loadDestinations() {
const workspace = $workspaceStore!
const workspace = $operatingWorkspace!
const [deployTo, remote] = await Promise.all([
WorkspaceService.getDeployTo({ workspace }),
WorkspaceService.getRemoteDeployTarget({ workspace }).catch(() => undefined)
])
if (workspace !== $workspaceStore) return
if (workspace !== $operatingWorkspace) return
parentWorkspace = deployTo.deploy_to
remoteStatus = remote
// Keep the user's pick across a reload, but only while this workspace still has it: the
@@ -430,7 +445,7 @@
}
$effect(() => {
$workspaceStore && untrack(() => loadDestinations())
$operatingWorkspace && untrack(() => loadDestinations())
})
// Until the caller has a token for the remote instance there is nothing to deploy against, so
@@ -439,7 +454,7 @@
destination === 'parent'
? parentWorkspace
: isRemote && remoteStatus?.connection
? remoteDeployWorkspace($workspaceStore!, remoteStatus.connection)
? remoteDeployWorkspace($operatingWorkspace!, remoteStatus.connection)
: undefined
)
let destinationLabel = $derived(
@@ -560,12 +575,12 @@
{#if isRemote && remoteTarget}
<div class="mt-3">
<RemoteDeployConnect
workspace={$workspaceStore!}
workspace={$operatingWorkspace!}
target={remoteTarget}
connection={remoteStatus?.connection}
returnTo={kind === 'trigger'
? undefined
: `${base}/deploy/${kind}/${initialPath}?workspace=${encodeURIComponent($workspaceStore!)}`}
: `${base}/deploy/${kind}/${initialPath}?workspace=${encodeURIComponent($operatingWorkspace!)}`}
onChange={loadDestinations}
/>
</div>
@@ -1,6 +1,7 @@
<script lang="ts" module>
import { z } from 'zod'
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
import { useHostedPage } from '$lib/components/hostedPage'
import { formatDatePretty, parsePrettyDate, type IconType } from '$lib/utils'
export type FilterSchemaRec = Record<string, FilterSchema>
@@ -134,8 +135,9 @@
// URL lacks, so a value written to the instance is undone on the next sync. Going through
// urlFilter rather than straight to history keeps the search-param cells in step, so it
// does not matter whether a popstate follows.
const hosted = useHostedPage()
function seed(values: Partial<FilterInstanceRec<T>>) {
const sp = new URLSearchParams(window.location.search)
const sp = new URLSearchParams(hosted ? untrack(() => hosted.search) : window.location.search)
for (const [key, value] of Object.entries(values) as [string, unknown][]) {
if (value === undefined || value === null || sp.has(key)) continue
urlFilter[key] = value instanceof Date ? value.toISOString() : value
@@ -4,27 +4,55 @@
* workspace and the admin bypass in its popover. Renders nothing for operators, who have no edit
* affordance for it to explain.
*/
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { userWorkspaces, workspaceStore } from '$lib/stores'
import { resource } from 'runed'
import {
canUserBypassRuleKind,
getActiveRulesetsForKind,
isRuleActive
canUserBypassRuleKindInRulesets,
fetchProtectionRulesForWorkspace,
getActiveRulesetsForKindInRulesets,
isRuleActiveInRulesets,
protectionRulesState
} from '$lib/workspaceProtectionRules.svelte'
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
import { devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
import { canCreateFork } from '$lib/utils/editInFork'
import { switchWorkspace } from '$lib/storeUtils'
import { Badge, Button } from './common'
import Popover from './meltComponents/Popover.svelte'
import Toggle from './Toggle.svelte'
import { GitFork, Lock, ShieldOff } from 'lucide-svelte'
import {
useOperatingWorkspace,
useOperatingUser
} from '$lib/components/operatingWorkspace.svelte'
let activeDeployRulesets = $derived(getActiveRulesetsForKind('DisableDirectDeployment'))
let canBypass = $derived(canUserBypassRuleKind('DisableDirectDeployment', $userStore))
let canonicalDev = $derived(findCanonicalDevWorkspace($workspaceStore, $userWorkspaces))
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
// The shared rules state holds the navigation workspace's rules, so a host acting on another
// workspace (an AI session's fork) has to ask for that one's.
const foreignRules = resource(
() => ($operatingWorkspace !== $workspaceStore ? $operatingWorkspace : undefined),
(workspace) => (workspace ? fetchProtectionRulesForWorkspace(workspace) : Promise.resolve([]))
)
let rulesets = $derived(
$operatingWorkspace === $workspaceStore
? (protectionRulesState.rulesets ?? [])
: (foreignRules.current ?? [])
)
let activeDeployRulesets = $derived(
getActiveRulesetsForKindInRulesets(rulesets, 'DisableDirectDeployment')
)
let canBypass = $derived(
canUserBypassRuleKindInRulesets(rulesets, 'DisableDirectDeployment', operatingUser.current)
)
let canonicalDev = $derived(findCanonicalDevWorkspace($operatingWorkspace, $userWorkspaces))
// Forking may itself be blocked by DisableWorkspaceForking, so only suggest it
// when the user can actually fork this workspace.
let canFork = $derived(canCreateFork($userStore))
let canFork = $derived(
!isRuleActiveInRulesets(rulesets, 'DisableWorkspaceForking') ||
canUserBypassRuleKindInRulesets(rulesets, 'DisableWorkspaceForking', operatingUser.current)
)
let editAdvice = $derived(
canFork
? 'You will need to either fork the workspace, or make your changes locally and submit a PR to an authorized user.'
@@ -34,7 +62,9 @@
// The toggle is only offered to a user who can bypass, but the answer can change under a
// workspace switch, so the checked flag alone never grants the edit.
let bypassActive = $derived(canBypass && overrideChecked)
let canEdit = $derived(!isRuleActive('DisableDirectDeployment') || bypassActive)
let canEdit = $derived(
!isRuleActiveInRulesets(rulesets, 'DisableDirectDeployment') || bypassActive
)
let {
onUpdateCanEditStatus = (value) => {}
@@ -55,7 +85,7 @@
)
</script>
{#if !$userStore?.operator && activeDeployRulesets.length > 0}
{#if !operatingUser.current?.operator && activeDeployRulesets.length > 0}
<div class="my-2">
<Popover
placement="bottom-start"
@@ -12,7 +12,6 @@
import OpenInSessionButton from './sessions/OpenInSessionButton.svelte'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
pageDrawerSessionSource,
setPageDrawerAnchor
} from './sessions/pageDrawerSession'
@@ -102,7 +101,6 @@
* dedicated editor elsewhere: the generic form would render its configuration field by field,
* and materialize a default into every one the value leaves out. */
export async function initEdit(p: string, opts?: { json?: boolean }): Promise<void> {
if (handOffPageDrawer(RESOURCES_PATH, p)) return
// A `close({ keepAnchor })` on an already-closed drawer emits no close event, so the flag
// would still be standing when the next drawer session ends and would swallow that one's
// anchor clear. Every session starts having to clear its own.
+24 -15
View File
@@ -10,7 +10,7 @@
} from '$lib/gen'
import type { Folder } from '$lib/gen/types.gen'
import { createEventDispatcher } from 'svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { Alert, Button, Drawer } from './common'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import { sendUserToast } from '$lib/toast'
@@ -22,11 +22,22 @@
import Toggle from './Toggle.svelte'
import { Trash } from 'lucide-svelte'
import { DEMO_RESTRICTION_HINT, isDemoWorkspaceRestricted } from '$lib/cloud'
import {
useOperatingWorkspace,
useOperatingUser
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const dispatch = createEventDispatcher()
let restricted = $derived(
isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin)
isDemoWorkspaceRestricted(
$operatingWorkspace,
operatingUser.current?.is_admin,
operatingUser.current?.is_super_admin
)
)
type Kind =
@@ -81,7 +92,7 @@
defaultPermsLabel = `Folder f/${folderName} permissions`
try {
const folder: Folder = await FolderService.getFolder({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
name: folderName
})
if (path !== currentPath) return
@@ -134,7 +145,7 @@
const currentPath = path
try {
const resource = await ResourceService.getResource({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
path: currentPath
})
if (path !== currentPath) return
@@ -164,27 +175,27 @@
}
async function loadOwner() {
own = isOwner(path, $userStore!, $workspaceStore!)
own = isOwner(path, operatingUser.current!, $operatingWorkspace!)
}
async function loadAcls() {
acls = Object.entries(
await GranularAclService.getGranularAcls({ workspace: $workspaceStore!, path, kind })
await GranularAclService.getGranularAcls({ workspace: $operatingWorkspace!, path, kind })
)
}
async function loadGroups(): Promise<void> {
groups = await GroupService.listGroupNames({ workspace: $workspaceStore! })
groups = await GroupService.listGroupNames({ workspace: $operatingWorkspace! })
}
async function loadUsernames(): Promise<void> {
usernames = await UserService.listUsernames({ workspace: $workspaceStore! })
usernames = await UserService.listUsernames({ workspace: $operatingWorkspace! })
}
async function deleteAcl(owner: string) {
try {
await GranularAclService.removeGranularAcls({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
path,
kind,
requestBody: { owner }
@@ -193,7 +204,7 @@
for (const varPath of linkedVarPaths) {
try {
await GranularAclService.removeGranularAcls({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
path: varPath,
kind: 'variable',
requestBody: { owner }
@@ -212,7 +223,7 @@
async function addAcl(owner: string, write: boolean) {
await GranularAclService.addGranularAcls({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
path,
kind,
requestBody: { owner, write }
@@ -221,7 +232,7 @@
for (const varPath of linkedVarPaths) {
try {
await GranularAclService.addGranularAcls({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
path: varPath,
kind: 'variable',
requestBody: { owner, write }
@@ -251,9 +262,7 @@
</div>
{/if}
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-emphasis"
>Extra members ({acls?.length ?? 0})</span
>
<span class="text-sm font-semibold text-emphasis">Extra members ({acls?.length ?? 0})</span>
{#if linkedVarPaths.length > 0}
<div class="flex flex-col gap-1.5 p-3 border rounded bg-surface-secondary text-xs">
<Toggle
@@ -12,10 +12,13 @@
import autosize from '$lib/autosize'
import { ResourceService, VariableService } from '$lib/gen'
import { oauthStore, workspaceStore } from '$lib/stores'
import { oauthStore } from '$lib/stores'
import Password from './Password.svelte'
import { createEventDispatcher } from 'svelte'
import HighlightTheme from './HighlightTheme.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
let drawer: Drawer | undefined = $state()
let token: undefined | string = $state(undefined)
@@ -78,7 +81,7 @@
async function save() {
if (!path) return
await VariableService.createVariable({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
requestBody: {
path,
value: password,
@@ -89,7 +92,7 @@
})
await ResourceService.createResource({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
requestBody: {
resource_type: 'postgresql',
path,
@@ -7,7 +7,6 @@
import OpenInSessionButton from './sessions/OpenInSessionButton.svelte'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
pageDrawerSessionSource,
setPageDrawerAnchor
} from './sessions/pageDrawerSession'
@@ -389,7 +388,6 @@
}
export function editVariable(edit_path: string): void {
if (handOffPageDrawer(VARIABLES_PATH, edit_path)) return
reset()
editPath = edit_path
selected = curWs!
@@ -4,7 +4,8 @@
import type { BadgeColor, BadgeIconProps } from '../common/badge/model'
import { appIconComponent } from '../icons'
import { integrationDisplayName } from '../resourceTypeDisplay'
import { onDestroy, onMount } from 'svelte'
import { untrack } from 'svelte'
import { useHostedPage } from '../hostedPage'
interface Props {
filters: string[]
@@ -39,42 +40,35 @@
inline = false
}: Props = $props()
const queryChange: (value: URL) => void = (url: URL) => {
if (syncQuery) {
window.history.pushState(history.state, '', `?${url?.searchParams.toString()}`)
}
}
const eventListener = (e: PopStateEvent) => {
if (syncQuery) {
loadFilterFromUrl()
}
}
onMount(() => {
window.addEventListener('popstate', eventListener)
})
onDestroy(() => {
window.removeEventListener('popstate', (e) => eventListener(e))
})
loadFilterFromUrl()
const hosted = useHostedPage()
function loadFilterFromUrl() {
let queryValue = new URL(window.location.href).searchParams.get(queryName) ?? undefined
selectedFilter = queryValue
const search = hosted ? hosted.search : window.location.search
selectedFilter = new URLSearchParams(search).get(queryName) ?? undefined
}
export async function setQuery(url: URL, key: string, value: string | undefined): Promise<void> {
if (value != undefined) {
url.searchParams.set(key, value)
} else {
url.searchParams.delete(key)
}
queryChange(url)
function writeQuery(value: string | undefined) {
if (!syncQuery) return
const params = new URLSearchParams(hosted ? hosted.search : window.location.search)
if (value != undefined) params.set(queryName, value)
else params.delete(queryName)
if (hosted) hosted.setSearch(params.size ? `?${params}` : '')
else window.history.pushState(history.state, '', `?${params}`)
}
$effect(() => {
if (!syncQuery) return
if (hosted) {
hosted.search
untrack(loadFilterFromUrl)
return
}
window.addEventListener('popstate', loadFilterFromUrl)
return () => window.removeEventListener('popstate', loadFilterFromUrl)
})
untrack(loadFilterFromUrl)
let filtersAndSelected = $derived(
selectedFilter
? filters.includes(selectedFilter)
@@ -109,11 +103,7 @@
class="inline-flex items-center gap-1 align-middle"
onclick={() => {
selectedFilter = selectedFilter == filter ? undefined : filter
if (selectedFilter) {
setQuery(new URL(window.location.href), queryName, selectedFilter)
} else {
setQuery(new URL(window.location.href), queryName, undefined)
}
writeQuery(selectedFilter)
}}
{color}
clickable
+31
View File
@@ -0,0 +1,31 @@
import { getContext, setContext } from 'svelte'
// A list page mounted somewhere other than its own route — an AI session's preview tab. The
// document URL there belongs to the host, so the page's view (the filters in its query) has to
// live wherever the host keeps it, and its rows and links open where the host decides instead of
// in a drawer or a top-level navigation. Components under such a host read this instead of
// `window.location`; outside one there is none, and they keep to the document.
export type HostedPage = {
/** The page's query, `''` or `?…`. Reactive: the host may re-point it. */
readonly search: string
/** Replace the page's query. */
setSearch(search: string): void
/** Open one of the page's rows, by path. */
openItem(path: string): void
/** Follow an in-app link (base included) the page would otherwise navigate to. */
openLink(href: string): void
}
const KEY = Symbol('hostedPage')
/** Reads context: call during component initialisation. */
export function setHostedPage(host: HostedPage): void {
setContext(KEY, host)
}
/** The host of this page, or undefined on the page's own route. Reads context: call during
* component initialisation. */
export function useHostedPage(): HostedPage | undefined {
return getContext<HostedPage | undefined>(KEY)
}
@@ -15,8 +15,10 @@
import { Badge } from '../common'
import { forLater } from '$lib/forLater'
import DurationMs from '../DurationMs.svelte'
import { workspaceStore } from '$lib/stores'
import { twMerge } from 'tailwind-merge'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const POPUP_HEIGHT = 320 as const
@@ -115,7 +117,7 @@
<span class="text-xs font-normal text-secondary">Arguments</span>
<JobArgs
id={job?.id}
workspace={job?.workspace_id ?? $workspaceStore ?? 'no_w'}
workspace={job?.workspace_id ?? $operatingWorkspace ?? 'no_w'}
args={job?.args}
/>
</div>
File diff suppressed because it is too large Load Diff
@@ -76,15 +76,14 @@ export function buildResourcesFilterSchema({
icon: Tag,
description: 'Filter by label (comma-separated for multiple)'
},
...(showUserFoldersFilter
? {
user_folders_only: {
type: 'boolean' as const,
label: userFoldersLabel || 'User folders only',
icon: Users,
description: 'Show only resources in user folders'
}
}
: {})
// The key stays for every role, hidden where the filter does not apply: the list's URL sync
// reads the key set once, before the acting user that gates it may have loaded.
user_folders_only: {
type: 'boolean' as const,
label: userFoldersLabel || 'User folders only',
icon: Users,
description: 'Show only resources in user folders',
hidden: !showUserFoldersFilter
}
} satisfies FilterSchemaRec
}
@@ -0,0 +1,718 @@
<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import {
ScheduleService,
type ScheduleWJobs,
type WorkspaceDeployUISettings,
WorkspaceService
} from '$lib/gen'
import { canWrite, displayDate, getLocalSetting, storeLocalSetting } from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { base } from '$app/paths'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
import Dropdown from '$lib/components/DropdownV2.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import Popover from '$lib/components/Popover.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { userWorkspaces, enterpriseLicense } from '$lib/stores'
import {
Calendar,
Circle,
Copy,
Eye,
FileUp,
List,
Loader2,
Pen,
Play,
Plus,
SearchX,
Shield,
Trash
} from 'lucide-svelte'
import { goto } from '$lib/navigation'
import { sendUserToast } from '$lib/toast'
import FilterSearchbar, {
hasActiveFilters,
useUrlSyncedFilterInstance
} from '$lib/components/FilterSearchbar.svelte'
import { buildSchedulesFilterSchema } from '$lib/components/schedules/schedulesFilter'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import JobPreview from '$lib/components/jobs/JobPreview.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { untrack } from 'svelte'
import { page } from '$app/stores'
import { useHostedPage } from '$lib/components/hostedPage'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
import { runScheduleNow } from '$lib/components/triggers/scheduled/utils'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import {
useOperatingWorkspace,
useOperatingUser
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const hosted = useHostedPage()
function editSchedule(path: string, isFlow: boolean) {
if (hosted) hosted.openItem(path)
else scheduleEditor?.openEdit(path, isFlow)
}
type ScheduleW = ScheduleWJobs & { canWrite: boolean }
let schedules: ScheduleW[] = $state([])
let shareModal: ShareModal | undefined = $state()
let loading = $state(true)
let loadingSchedulesWithJobStats = $state(true)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getPublicSettings({ workspace: $operatingWorkspace! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
async function loadSchedules(): Promise<void> {
const currentFilters = filters.val
// Build API parameters from filters.
// `includeDraftOnly` surfaces per-user drafts at paths that have
// no deployed schedule — appended server-side when no narrowing
// filter is set, so the user sees AI-agent-created drafts on
// the home page.
const apiParams: any = {
workspace: $operatingWorkspace!,
includeDraftOnly: true
}
if (currentFilters.schedule_path) {
apiParams.schedulePath = currentFilters.schedule_path
}
if (currentFilters.path_start) {
apiParams.pathStart = currentFilters.path_start
}
if (currentFilters.path) {
apiParams.path = currentFilters.path
}
if (currentFilters.description) {
apiParams.description = currentFilters.description
}
if (currentFilters.summary) {
apiParams.summary = currentFilters.summary
}
if (currentFilters.args) {
apiParams.args = currentFilters.args
}
if (currentFilters._default_) {
apiParams.broadFilter = currentFilters._default_
}
if (currentFilters.label) {
apiParams.label = currentFilters.label
}
const result = (await ScheduleService.listSchedules(apiParams)).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, operatingUser.current), ...x }
})
// Extract unique values for autocomplete
allPaths = Array.from(new Set(result.map((x) => x.path))).sort()
allScriptPaths = Array.from(new Set(result.map((x) => x.script_path))).sort()
allLabels = Array.from(
new Set(result.flatMap((x) => [...(x.labels ?? []), ...(x.inherited_labels ?? [])]))
).sort()
schedules = result
loading = false
// after the schedule core data has been loaded, load all the job stats
// TODO: we could potentially not reload the job stats on every call to loadSchedules, but for now it's
// simpler to always call it. Update if performance becomes an issue.
loadSchedulesWithJobStats()
}
// Reload schedules when filters change
$effect(() => {
filters.val
if ($operatingWorkspace) {
untrack(() => loadSchedules())
}
})
async function loadSchedulesWithJobStats(): Promise<void> {
loadingSchedulesWithJobStats = true
let schedulesWithJobsByPath = new Map<string, ScheduleW>()
let schedulesWithJobsList = await ScheduleService.listSchedulesWithJobs({
workspace: $operatingWorkspace!
})
schedulesWithJobsList.map((x) => {
schedulesWithJobsByPath[x.path] = x
})
for (let schedule of schedules) {
if (schedulesWithJobsByPath[schedule.path]) {
schedule.jobs = schedulesWithJobsByPath[schedule.path].jobs
}
}
loadingSchedulesWithJobStats = false
}
// Per-path counter bumped when a schedule toggle is cancelled or errors,
// to force-remount that row's <Toggle>. Toggle uses `bind:checked` on
// its native input; once the user clicks, the local checkbox state
// diverges from the parent's prop expression, and Svelte 5 prop
// reactivity won't push a same-valued prop back down. Re-mounting
// re-initializes from the prop. List-page rows don't optimistically
// flip `enabled`, so they need this nudge — but only the affected row,
// not all rows on the page.
let toggleResetVersions = $state<Record<string, number>>({})
function bumpToggleReset(path: string) {
toggleResetVersions[path] = (toggleResetVersions[path] ?? 0) + 1
}
async function setScheduleEnabled(path: string, enabled: boolean): Promise<void> {
try {
const ok = await withForkConflictRetry(
(force) =>
ScheduleService.setScheduleEnabled({
path,
workspace: $operatingWorkspace!,
requestBody: { enabled, force }
}),
'schedule'
)
if (ok) {
loadSchedules()
} else {
// Cancelled — nothing changed on the server, skip the reload
// (which would re-fetch job stats and flash the loading flag)
// and just nudge the toggle back to the prop value.
bumpToggleReset(path)
}
} catch (err) {
sendUserToast(`Cannot ` + (enabled ? 'enable' : 'disable') + ` schedule: ${err.body}`, true)
bumpToggleReset(path)
loadSchedules()
}
}
$effect(() => {
if ($operatingWorkspace && operatingUser.current) {
untrack(() => {
loadSchedules()
})
}
})
let scheduleEditor: ScheduleEditor | undefined = $state()
// Deep link: #<path> opens that schedule's edit drawer. Tracks the last
// handled hash (not a one-shot flag) so a hash change on the already-mounted
// page (e.g. the AI session preview re-pointing its tab) opens the drawer
// too. Row links pre-set handledHash: their onclick already opens the drawer.
let handledHash = ''
$effect(() => {
// A host opens rows itself, and the document's hash is not this page's.
if (hosted) return
const hash = $page.url.hash
if (hash.length <= 1) {
// Navigating away from a drawer target must clear the tracker, or
// re-targeting the same schedule later would be skipped as already handled.
handledHash = ''
return
}
if (hash === handledHash || schedules.length === 0 || !scheduleEditor) return
const path = hash.slice(1)
const schedule = schedules.find((s) => s.path === path)
if (schedule) {
handledHash = hash
scheduleEditor.openEdit(path, schedule.is_flow)
}
})
// Collect unique values for filter autocomplete
let allPaths: string[] = $state([])
let allScriptPaths: string[] = $state([])
let allLabels: string[] = $state([])
// FilterSearchbar setup
let userFoldersFilterType = $derived(
operatingUser.current?.is_super_admin && operatingUser.current.username.includes('@')
? 'only f/*'
: operatingUser.current?.is_admin || operatingUser.current?.is_super_admin
? 'u/username and f/*'
: undefined
)
let schedulesFilterSchema = $derived(
buildSchedulesFilterSchema({
paths: allPaths,
scriptPaths: allScriptPaths,
labels: allLabels,
showUserFoldersFilter: userFoldersFilterType !== undefined,
userFoldersLabel:
userFoldersFilterType === 'only f/*'
? 'Only f/*'
: `Only u/${operatingUser.current?.username} and f/*`
})
)
let filters = useUrlSyncedFilterInstance(untrack(() => schedulesFilterSchema))
// A link can carry the user folders filter to someone it is not offered to, where it would sit
// set with no chip to clear it. Dropped only once a user is actually known: `resolved` holds for
// the navigation workspace even while its user is still loading.
$effect(() => {
if (!operatingUser.resolved($operatingWorkspace) || !operatingUser.current) return
if (userFoldersFilterType !== undefined) return
if (!filters.val.user_folders_only) return
untrack(() => {
const { user_folders_only: _, ...rest } = filters.val
filters.val = rest
})
})
let activeFilters = $derived(hasActiveFilters(filters.val))
let allFolders = $derived(
Array.from(
new Set(
(schedules ?? [])
.map((x) => x.path.split('/').slice(0, 2).join('/'))
.filter((x) => x.startsWith('f/'))
)
)
.sort()
.map((f) => f.replace(/^f\//, ''))
)
let presets = $derived([
...allFolders.map((f) => ({ name: `f/${f}`, value: `path_start:\\ f/${f}/` })),
...allLabels.map((l) => ({ name: l, value: `label:\\ ${l}` })),
...(!schedulesFilterSchema.user_folders_only.hidden
? [
{
name: schedulesFilterSchema.user_folders_only.label ?? '?',
value: 'user_folders_only:\\ true'
}
]
: [])
])
let nbDisplayed = $state(15)
let filterEnabledDisabled: 'all' | 'enabled' | 'disabled' = $state('all')
const SCHEDULE_PATH_KIND_FILTER_SETTING = 'schedulePathKindFilter'
let selectedFilterKind = $state(
(getLocalSetting(SCHEDULE_PATH_KIND_FILTER_SETTING) as 'schedule' | 'script_flow') ?? 'schedule'
)
$effect(() => {
storeLocalSetting(SCHEDULE_PATH_KIND_FILTER_SETTING, selectedFilterKind)
})
function filterItemsPathsBaseOnUserFilters(
item: ScheduleW,
selectedFilterKind: 'schedule' | 'script_flow',
userFoldersOnly: boolean
) {
if (userFoldersOnly) {
if (selectedFilterKind === 'schedule') {
return (
!item.path.startsWith('u/') ||
item.path.startsWith('u/' + operatingUser.current?.username + '/')
)
} else {
return (
!item.script_path.startsWith('u/') ||
item.script_path.startsWith('u/' + operatingUser.current?.username + '/')
)
}
} else {
return true
}
}
function filterItemsBasedOnEnabledDisabled(
item: ScheduleW,
filterEnabledDisabled: 'all' | 'enabled' | 'disabled'
) {
// Draft-only rows have nothing deployed, so they read as disabled here
// too — matching the off, disabled toggle the row renders.
const enabled = !item.draft_only && item.enabled
if (filterEnabledDisabled === 'all') return true
if (filterEnabledDisabled === 'enabled') return enabled
if (filterEnabledDisabled === 'disabled') return !enabled
}
// Filter schedules client-side for enabled/disabled and user folders
let filteredItems = $derived.by(() => {
return schedules?.filter(
(x) =>
filterItemsPathsBaseOnUserFilters(
x,
selectedFilterKind,
userFoldersFilterType !== undefined && !!filters.val.user_folders_only
) && filterItemsBasedOnEnabledDisabled(x, filterEnabledDisabled)
)
})
let items = $derived(filteredItems)
</script>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<ScheduleEditor onUpdate={loadSchedules} bind:this={scheduleEditor} />
{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.schedules}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{:else}
<CenteredPage>
<PageHeader
title="Schedules"
tooltip="Trigger Scripts and Flows according to a cron schedule"
documentationLink="https://www.windmill.dev/docs/core_concepts/scheduling"
>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => scheduleEditor?.openNew(false)}
aiId="schedules-add-schedule"
aiDescription="Add schedule"
>
New schedule
</Button>
</PageHeader>
<div class="w-full h-full flex flex-col">
<div class="flex flex-row items-center justify-end gap-4 pb-4">
<ToggleButtonGroup bind:selected={filterEnabledDisabled} class="w-fit">
{#snippet children({ item })}
<ToggleButton value="all" label="All" {item} />
<ToggleButton value="enabled" label="Enabled" {item} />
<ToggleButton value="disabled" label="Disabled" {item} />
{/snippet}
</ToggleButtonGroup>
<FilterSearchbar
schema={schedulesFilterSchema}
bind:value={filters.val}
class="grow max-w-[26rem]"
{presets}
/>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !schedules?.length}
{#if activeFilters}
<EmptyState
icon={SearchX}
title="No schedules found"
description="No schedule matches the current filters. Try clearing or widening them."
/>
{:else}
<EmptyState
icon={Calendar}
title="No schedules yet"
description="Schedules run a script or flow automatically on a cron expression."
action={{
label: 'Add a schedule',
icon: Plus,
onClick: () => scheduleEditor?.openNew(false),
aiId: 'schedules-empty-add',
aiDescription: 'Add schedule'
}}
/>
{/if}
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as { path, error, summary, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, jobs, paused_until, labels, inherited_labels, draft_only, is_draft } (path)}
{@const hasDraft =
getLocalDraftHint($operatingWorkspace, 'trigger_schedule', path) ?? is_draft}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
{@const avg_s = jobs
? jobs.reduce((acc, x) => acc + x.duration_ms, 0) / jobs.length
: undefined}
<div
class="bg-surface-tertiary hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-4 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
onclick={() => {
handledHash = `#${path}`
editSchedule(path, is_flow)
}}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div
class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate"
>
{summary || script_path}{hasDraft ? '*' : ''}
</div>
<div class="text-secondary text-xs truncate text-left">
schedule: {path}
</div>
</a>
{#if labels?.length}
{#each labels as label}
<Badge
color="blue"
small
class="px-1"
title="Label: {label}"
clickable
onclick={() => {
const arr = (filters.val.label ?? '').split(',').filter(Boolean)
const idx = arr.indexOf(label)
if (idx >= 0) arr.splice(idx, 1)
else arr.push(label)
const newFilters = { ...filters.val }
if (arr.length) newFilters.label = arr.join(',')
else delete newFilters.label
filters.val = newFilters
}}>{label}</Badge
>
{/each}
{/if}
<InheritedLabels labels={inherited_labels} />
{#if paused_until && new Date(paused_until) > new Date()}
<div class="pb-1">
<Badge color="yellow"
>Paused until {new Date(paused_until).toLocaleString()}</Badge
>
</div>
{/if}
<div class="gap-2 items-center hidden md:flex">
<Badge large color="blue">{schedule}</Badge>
<Badge small color="gray">{timezone}</Badge>
</div>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
</div>
<div class="w-10">
{#if error}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle
class="text-red-600 animate-ping absolute inline-flex fill-current"
size={12}
/>
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div>
The schedule disabled itself because there was an error scheduling the
next job: {error}
</div>
{/snippet}
</Popover>
{/if}
</div>
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
<DraftBadge {draft_only} is_draft={hasDraft} />
{#key toggleResetVersions[path] ?? 0}
<Toggle
disabled={draft_only}
options={{
title: draft_only
? 'Draft only: deploy the schedule to enable it'
: hasDraft
? 'Enables/disables the deployed schedule; the draft is not affected'
: undefined
}}
checked={!draft_only && enabled}
on:change={(e) => {
if (canWrite) {
setScheduleEnabled(path, e.detail)
} else {
sendUserToast('not enough permission', true)
// Permission denied — bump the row's reset
// counter so the Toggle remounts back to the
// prop value. Without this, the local
// `bind:checked` flip from the user's click
// stays stuck on.
bumpToggleReset(path)
}
}}
/>
{/key}
</div>
<div class="flex gap-2 items-center justify-end">
<Button
href={`${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true`}
disabled={draft_only}
unifiedSize="md"
startIcon={{ icon: List }}
variant="subtle"
>
Runs
</Button>
<Button
on:click={() => editSchedule(path, is_flow)}
unifiedSize="md"
startIcon={{ icon: canWrite ? Pen : Eye }}
variant="subtle"
>
{canWrite ? 'Edit' : 'View'}
</Button>
<Dropdown
size="md"
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
if (hosted) hosted.openLink(`${base}${href}`)
else goto(href)
}
},
{
displayName: `Duplicate schedule`,
icon: Copy,
action: () => {
scheduleEditor?.openNew(is_flow, script_path, path)
}
},
{
displayName: 'Delete',
type: 'delete',
icon: Trash,
disabled: !canWrite,
action: async () => {
await ScheduleService.deleteSchedule({
workspace: $operatingWorkspace ?? '',
path
})
loadSchedules()
}
},
{
displayName: canWrite ? 'Edit' : 'View',
icon: canWrite ? Pen : Eye,
action: () => {
editSchedule(path, is_flow)
}
},
...(isDeployable('trigger', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer?.openDrawer(path, 'trigger', {
triggers: {
kind: 'schedules'
}
})
}
}
]
: []),
{
displayName: 'View runs',
icon: List,
href: `${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true`
},
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`
},
{
displayName: 'Run now',
icon: Play,
action: () => {
runScheduleNow(script_path, path, is_flow, $operatingWorkspace!)
}
},
{
displayName: 'Permissions',
icon: Shield,
action: () => {
shareModal?.openDrawer(path, 'schedule')
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
{#if loadingSchedulesWithJobStats}
<div class="flex gap-1 ml-0.5 text-xs text-secondary items-center">
<Loader2 size={14} class="animate-spin" />
<span>Job stats loading...</span>
</div>
{:else}
<div class="flex gap-1.5 ml-0.5 items-baseline flex-row-reverse">
{#if avg_s}
<div class="pl-2 text-secondary text-xs"
>Avg: {(avg_s / 1000).toFixed(2)}s</div
>
{/if}
{#each jobs ?? [] as job}
{@const h = (avg_s ? job.duration_ms / avg_s : 1) * 7 + 3}
<a href="{base}/run/{job.id}?workspace={$operatingWorkspace}">
<JobPreview id={job.id} class="p-4">
<div>
<div
class="{job.success ? 'bg-green-300' : 'bg-red-300'} mx-auto w-1.5"
style="height: {h}px"
></div>
<!-- <div class="text-[0.6em] mt-0.5 text-center text-primary"
>{(job.duration_ms / 1000).toFixed(2)}s</div
> -->
</div>
</JobPreview>
</a>
{/each}
</div>
{/if}
<div
class="flex flex-wrap text-xs text-secondary gap-1 items-center justify-end truncate pr-2"
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
class="truncate">{edited_by ? 'at ' : ''}{displayDate(edited_at)}</div
></div
></div
>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
<div class="flex items-center gap-4 text-xs font-semibold text-emphasis">
<span>{nbDisplayed} items out of {items.length}</span>
<Button unifiedSize="sm" variant="subtle" on:click={() => (nbDisplayed += 30)}>
Load 30 more
</Button>
</div>
{/if}
</CenteredPage>
{/if}
<ShareModal
bind:this={shareModal}
on:change={() => {
loadSchedules()
}}
/>
@@ -73,15 +73,14 @@ export function buildSchedulesFilterSchema({
icon: Tag,
description: 'Filter by label (comma-separated for multiple)'
},
...(showUserFoldersFilter
? {
user_folders_only: {
type: 'boolean' as const,
label: userFoldersLabel || 'User folders only',
icon: Users,
description: 'Show only schedules in user folders'
}
}
: {})
// The key stays for every role, hidden where the filter does not apply: the list's URL sync
// reads the key set once, before the acting user that gates it may have loaded.
user_folders_only: {
type: 'boolean' as const,
label: userFoldersLabel || 'User folders only',
icon: Users,
description: 'Show only schedules in user folders',
hidden: !showUserFoldersFilter
}
} satisfies FilterSchemaRec
}
@@ -0,0 +1,131 @@
<script lang="ts">
import { setContext, untrack } from 'svelte'
import { Loader2 } from 'lucide-svelte'
import { setOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { setHostedPage, type HostedPage } from '$lib/components/hostedPage'
import { pageItemForListPath, parsePreviewItemRoute } from './previewPaths'
import { previewLocationLabel } from './previewRouter'
import { whereIs } from './sessionPreviewTabs.svelte'
import type { SessionPreviewTab } from './sessionState.svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
let {
runtime,
tab,
path,
workspaceId,
container,
reloadNonce = 0
}: {
runtime: SessionRuntime
tab: SessionPreviewTab
/** The list page, base-stripped. */
path: string
workspaceId: string
/** The tab's element, which also holds the list's portalled drawers and menus. */
container: HTMLElement
/** Bumped to load the list again from the server. */
reloadNonce?: number
} = $props()
// Mounted in the preview panel, outside the chat's own subtree, as PageItemEditorView is.
setContext(
'aiChatManager',
untrack(() => runtime.manager)
)
setOperatingWorkspace(() => workspaceId)
// The tab is where the list keeps its filters: written as the tab's observed location, so
// the chat reads them and a remount restores them, as it would for a frame.
const locationParts = () => {
const loc = whereIs(tab).split('#')[0]
const at = loc.indexOf('?')
return at < 0
? { pathname: loc, search: '' }
: { pathname: loc.slice(0, at), search: loc.slice(at) }
}
const host: HostedPage = {
get search() {
return locationParts().search
},
setSearch(search) {
runtime.previewTabs.observeLocation(tab.id, locationParts().pathname + search)
},
openItem(itemPath) {
const ref = pageItemForListPath(path, itemPath)
if (ref) runtime.previewTabs.open({ type: 'pageitem', ref })
},
openLink(href) {
const item = parsePreviewItemRoute(href)
if (item) {
runtime.previewTabs.open({
type: 'item',
item: { kind: item.kind, raw_app: item.raw_app, path: item.itemPath, summary: '' }
})
} else {
runtime.previewTabs.open({ type: 'page', href, label: previewLocationLabel(href) })
}
}
}
setHostedPage(host)
// Any in-app link under the tab opens in the session rather than taking the whole window off
// it: a list, its drawers and its menus carry plain hrefs throughout (runs, audit logs, a
// job). A `#` link is a row's own: its handler opens the row through the host, and following
// it would write the row into the sessions page's own hash.
$effect(() => {
const el = container
function onClick(e: MouseEvent) {
if (e.defaultPrevented || e.button !== 0) return
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
const a = (e.target as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download')) return
const url = new URL(a.href, window.location.href)
if (url.origin !== window.location.origin) return
e.preventDefault()
if (a.getAttribute('href')?.startsWith('#')) return
host.openLink(url.pathname + url.search + url.hash)
}
el.addEventListener('click', onClick)
return () => el.removeEventListener('click', onClick)
})
const listed = $derived(pageItemForListPath(path, ''))
</script>
{#snippet loading()}
<div class="flex-1 flex items-center justify-center text-tertiary">
<Loader2 class="animate-spin" />
</div>
{/snippet}
<!-- Dynamic imports, as the editors: each list pulls in its item's editor. -->
<div class="flex h-full min-h-0 flex-col overflow-auto">
{#key `${path}:${workspaceId}:${reloadNonce}`}
{#if listed?.kind === 'variable'}
{#await import('$lib/components/variables/VariablesList.svelte')}
{@render loading()}
{:then Module}
<Module.default />
{/await}
{:else if listed?.kind === 'resource'}
{#await import('$lib/components/resources/ResourcesList.svelte')}
{@render loading()}
{:then Module}
<Module.default />
{/await}
{:else if listed?.kind === 'schedule'}
{#await import('$lib/components/schedules/SchedulesList.svelte')}
{@render loading()}
{:then Module}
<Module.default />
{/await}
{:else if listed?.kind === 'trigger'}
{#await import('$lib/components/triggers/TriggerList.svelte')}
{@render loading()}
{:then Module}
<Module.default triggerKind={listed.triggerKind} />
{/await}
{/if}
{/key}
</div>
@@ -105,6 +105,7 @@
// A page item's editor reads its draft only when it loads, so a reload remounts it.
let pageItemReloadNonce = $state(0)
let pageListReloadNonce = $state(0)
export function reload() {
// A live editor shares the runtime store the chat mutates, so generic chat
@@ -116,6 +117,10 @@
pageItemReloadNonce++
return
}
if (slot.kind === 'pagelist') {
pageListReloadNonce++
return
}
try {
const win = frame?.contentWindow
if (!win) return
@@ -334,6 +339,27 @@
{/await}
{/if}
</div>
{:else if slot.kind === 'pagelist' && mounted && runtime}
<div
bind:this={overlayHostEl}
class="absolute inset-0 flex flex-col min-h-0 bg-surface {visibility}"
aria-hidden={!active}
>
{#if overlayHostEl}
{#await import('./PageListView.svelte')}
{@render editorLoading()}
{:then Module}
<Module.default
{runtime}
{tab}
path={slot.path}
{workspaceId}
container={overlayHostEl}
reloadNonce={pageListReloadNonce}
/>
{/await}
{/if}
</div>
{:else if slot.kind === 'artifact' && mounted}
<div
bind:this={overlayHostEl}
@@ -21,7 +21,6 @@ import {
type TriggerKind
} from './previewPaths'
import type { OpenInSessionSource } from './OpenInSessionButton.svelte'
import { isSessionPreviewFrame } from './sessionMode.svelte'
// The draft each page's drawer edits. The preview loads the page in its own
// document and reads the draft back from the server, so opening a session has to
@@ -84,34 +83,6 @@ export function setPageDrawerAnchor(pagePath: string, itemPath: string | undefin
history.replaceState(history.state, '', `${pathname}${search}${anchor}`)
}
/**
* Inside a session preview frame, hand a list page row up to the session, which edits it in
* a tab of its own. True when handed off: the caller must then not open its drawer. False
* off that page, and outside a preview frame, where the drawer is how the row is edited.
*/
export function handOffPageDrawer(pagePath: string, itemPath: string | undefined): boolean {
if (!itemPath || !isSessionPreviewFrame()) return false
if (stripBase(window.location.pathname) !== pagePath) return false
try {
window.parent.postMessage(
{ type: 'wm.session.openPageItem', pagePath, path: itemPath },
window.location.origin
)
} catch {
return false
}
// A frame left on a row's hash claims a row nobody has open here, and reopens its tab on
// every reload. Not through the router: these pages open their drawer from the hash. Once
// more after the event: a row link's `href="#<path>"` lands after its click handler.
const dropAnchor = () => {
const { pathname, search, hash } = window.location
if (hash) history.replaceState(history.state, '', `${pathname}${search}`)
}
dropAnchor()
setTimeout(dropAnchor, 0)
return true
}
/**
* Drop the row a list page deep-links, once its drawer closes. The hash is how the row was
* requested; leaving it behind makes the location claim a drawer that is no longer open —
@@ -79,6 +79,11 @@ export function pageItemListPath(ref: PageItemRef): string {
}
}
/** Whether a (base-stripped) path is the list page of a page item kind. */
export function isPageItemListPath(path: string): boolean {
return pageItemForListPath(path, '') !== undefined
}
/** The page item a list page's row names, or undefined for a page that lists none. */
export function pageItemForListPath(pagePath: string, path: string): PageItemRef | undefined {
const clean = stripBase(pagePath)
@@ -284,6 +284,17 @@ describe('resolvePreviewTab', () => {
expect(resolvePreviewTab('/runs')).toEqual({ kind: 'iframe' })
})
it('mounts the list page of a page item kind in process, whatever its filters', () => {
expect(resolvePreviewTab('/variables?path_start=f%2Fa%2F')).toEqual({
kind: 'pagelist',
path: '/variables'
})
expect(resolvePreviewTab('/kafka_triggers')).toEqual({
kind: 'pagelist',
path: '/kafka_triggers'
})
})
it('routes any script item to a live editor', () => {
expect(resolvePreviewTab('/scripts/edit/f/foo/bar')).toEqual({
kind: 'editor',
@@ -3,6 +3,7 @@ import {
AUDIT_LOGS_PATH,
FOLDERS_PATH,
GROUPS_PATH,
isPageItemListPath,
pageItemForListPath,
pageItemListPath,
pageItemPageHref,
@@ -530,13 +531,14 @@ export const isArtifactKey = (key: string) => key.startsWith('artifact:')
// fallback. Any editable item of a wrappable kind (script, flow, raw app) mounts
// its per-(kind,path) cell editor; a `/pipeline/<folder>` route mounts the
// data-pipeline graph editor (single, shared runtime.pipelineEditorState — `path`
// is the folder); everything else (static pages, regular drag-and-drop apps, any
// other route) stays an iframe.
// is the folder); the list page of a page item kind mounts that list; everything
// else (static pages, regular drag-and-drop apps, any other route) stays an iframe.
export type PreviewSlot =
| { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string }
| { kind: 'artifact'; id: string; version?: number }
| { kind: 'runform'; toolCallId: string }
| { kind: 'pageitem'; ref: PageItemRef }
| { kind: 'pagelist'; path: string }
| { kind: 'iframe' }
export function resolvePreviewTab(url: string): PreviewSlot {
@@ -546,22 +548,24 @@ export function resolvePreviewTab(url: string): PreviewSlot {
if (runForm) return { kind: 'runform', toolCallId: runForm.toolCallId }
const pageItem = parsePageItemRoute(url)
if (pageItem) return { kind: 'pageitem', ref: pageItem }
const route = stripBase(url)
if (isPageItemListPath(route)) return { kind: 'pagelist', path: route }
const pipelineFolder = parsePipelineRoute(url)
if (pipelineFolder) {
return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder }
}
const route = parsePreviewItemRoute(url)
if (!route) return { kind: 'iframe' }
const item = parsePreviewItemRoute(url)
if (!item) return { kind: 'iframe' }
const editorKind: SessionTargetKind | undefined =
route.kind === 'script'
item.kind === 'script'
? 'script'
: route.kind === 'flow'
: item.kind === 'flow'
? 'flow'
: route.kind === 'app' && route.raw_app
: item.kind === 'app' && item.raw_app
? 'raw_app'
: undefined
if (!editorKind) return { kind: 'iframe' }
return { kind: 'editor', editorKind, path: route.itemPath }
return { kind: 'editor', editorKind, path: item.itemPath }
}
/** The full workspace page showing what a tab shows ("Open in workspace"), or undefined when
@@ -576,6 +580,7 @@ export function workspacePageHref(location: string): string | undefined {
case 'pageitem':
return pageItemPageHref(slot.ref)
case 'editor':
case 'pagelist':
case 'iframe':
return location
default: {
@@ -406,7 +406,8 @@ function createRuntime(session: Session): SessionRuntime {
const tab = owner?.displayedTab
if (!tab) return undefined
const slotKind = resolvePreviewTab(tab.url).kind
if (slotKind !== 'iframe' && slotKind !== 'pageitem') return undefined
if (slotKind !== 'iframe' && slotKind !== 'pageitem' && slotKind !== 'pagelist')
return undefined
return previewLocationContext(whereIs(tab))
}
// Pre-flight: materialise the (still-transient) session, then commit
@@ -0,0 +1,983 @@
<script lang="ts">
import { onDestroy, untrack } from 'svelte'
import { base } from '$app/paths'
import { page } from '$app/stores'
import {
AzureTriggerService,
GcpTriggerService,
PostgresTriggerService,
SettingService,
WorkspaceService,
type TriggerMode,
type WorkspaceDeployUISettings
} from '$lib/gen'
import {
canWrite,
capitalize,
copyToClipboard,
displayDate,
getLocalSetting,
removeTriggerKindIfUnused,
sendUserToast,
storeLocalSetting
} from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import { enterpriseLicense, usedTriggerKinds, userWorkspaces, workspaceStore } from '$lib/stores'
import { goto, setQuery } from '$lib/navigation'
import { isCloudHosted } from '$lib/cloud'
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
import {
Circle,
ClipboardCopy,
Code,
Database,
Eye,
FileUp,
Mail,
Pause,
Pen,
Plus,
Route,
Shield,
Trash,
Unplug
} from 'lucide-svelte'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import Dropdown from '$lib/components/DropdownV2.svelte'
import ListFilters from '$lib/components/home/ListFilters.svelte'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import Popover from '$lib/components/Popover.svelte'
import SearchItems from '$lib/components/SearchItems.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import AmqpIcon from '$lib/components/icons/AmqpIcon.svelte'
import AwsIcon from '$lib/components/icons/AwsIcon.svelte'
import AzureIcon from '$lib/components/icons/AzureIcon.svelte'
import GoogleCloudIcon from '$lib/components/icons/GoogleCloudIcon.svelte'
import KafkaIcon from '$lib/components/icons/KafkaIcon.svelte'
import MqttIcon from '$lib/components/icons/MqttIcon.svelte'
import NatsIcon from '$lib/components/icons/NatsIcon.svelte'
import TriggerModeToggle from './TriggerModeToggle.svelte'
import { getHttpRoute } from './http/utils'
import { getEmailAddress, getEmailDomain } from './email/utils'
import { TRIGGER_LIST_CONFIG, type TriggerRow } from './triggerListConfig'
import type { TriggerKind } from '$lib/components/sessions/previewPaths'
import { useHostedPage } from '$lib/components/hostedPage'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
let { triggerKind }: { triggerKind: TriggerKind } = $props()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const hosted = useHostedPage()
const config = $derived(TRIGGER_LIST_CONFIG[triggerKind])
const isAdmin = $derived(
!!(operatingUser.current?.is_admin || operatingUser.current?.is_super_admin)
)
type EditorHandle = {
openNew: (isFlow: boolean) => void
openEdit: (path: string, isFlow: boolean) => void
}
type Module = { default: any }
const EDITORS: Record<TriggerKind, () => Promise<Module>> = {
http: () => import('./http/RouteEditor.svelte'),
websocket: () => import('./websocket/WebsocketTriggerEditor.svelte'),
postgres: () => import('./postgres/PostgresTriggerEditor.svelte'),
kafka: () => import('./kafka/KafkaTriggerEditor.svelte'),
nats: () => import('./nats/NatsTriggerEditor.svelte'),
sqs: () => import('./sqs/SqsTriggerEditor.svelte'),
gcp: () => import('./gcp/GcpTriggerEditor.svelte'),
azure: () => import('./azure/AzureTriggerEditor.svelte'),
mqtt: () => import('./mqtt/MqttTriggerEditor.svelte'),
amqp: () => import('./amqp/AmqpTriggerEditor.svelte'),
email: () => import('./email/EmailTriggerEditor.svelte')
}
const ICONS: Record<TriggerKind, any> = {
http: Route,
websocket: Unplug,
postgres: Database,
kafka: KafkaIcon,
nats: NatsIcon,
sqs: AwsIcon,
gcp: GoogleCloudIcon,
azure: AzureIcon,
mqtt: MqttIcon,
amqp: AmqpIcon,
email: Mail
}
const Icon = $derived(ICONS[triggerKind])
type TriggerW = TriggerRow & { canWrite: boolean }
let triggers: TriggerW[] = $state([])
let loading = $state(true)
let editor: EditorHandle | undefined = $state()
let shareModal: ShareModal | undefined = $state()
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let routesGenerator: { openDrawer: () => void } | undefined = $state()
let openAPISpecGenerator: { openDrawer: () => void } | undefined = $state()
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
let globalHttpWorkspacedRoute = $state(false)
let emailDomain: string | null = $state(null)
function openEdit(path: string, isFlow: boolean) {
if (hosted) hosted.openItem(path)
else editor?.openEdit(path, isFlow)
}
function openLink(href: string) {
if (hosted) hosted.openLink(`${base}${href}`)
else goto(href)
}
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getPublicSettings({ workspace: $operatingWorkspace! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
if (untrack(() => triggerKind) === 'http') {
SettingService.getGlobal({ key: 'http_route_workspaced_route' })
.then((setting) => (globalHttpWorkspacedRoute = (setting as boolean) ?? false))
.catch(() => (globalHttpWorkspacedRoute = false))
}
async function loadTriggers(): Promise<void> {
const workspace = $operatingWorkspace!
const listed = await config.list({ workspace, includeDraftOnly: true })
triggers = listed.map((x) => ({
canWrite: canWrite(x.path, x.extra_perms!, operatingUser.current),
...x
}))
// The nav rail lists the trigger kinds of the navigation workspace only.
if (workspace === $workspaceStore) {
$usedTriggerKinds = removeTriggerKindIfUnused(
triggers.length,
config.usedKind,
$usedTriggerKinds
)
}
if (triggerKind === 'email') emailDomain = await getEmailDomain()
loading = false
}
$effect(() => {
if ($operatingWorkspace && operatingUser.current) {
untrack(() => loadTriggers())
}
})
let interval: ReturnType<typeof setInterval> | undefined = untrack(() => config.status)
? setInterval(async () => {
try {
const newTriggers = await config.list({ workspace: $operatingWorkspace! })
for (let i = 0; i < triggers.length; i++) {
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
if (newTrigger) {
triggers[i] = {
...triggers[i],
error: newTrigger.error,
last_server_ping: newTrigger.last_server_ping,
mode: newTrigger.mode,
server_id: newTrigger.server_id
}
}
}
} catch (err) {
console.error(err)
}
}, 5000)
: undefined
onDestroy(() => clearInterval(interval))
function modeVerb(mode: TriggerMode) {
return mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend'
}
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
const workspace = $operatingWorkspace!
const label = config.forkConflictLabel
if (label === undefined) {
try {
await config.setMode({ path, workspace, requestBody: { mode } })
} catch (err) {
sendUserToast(`${config.modeError(modeVerb(mode))}: ${err.body}`, true)
} finally {
loadTriggers()
}
return true
}
let committed = false
try {
const ok = await withForkConflictRetry(
(force) => config.setMode({ path, workspace, requestBody: { mode, force } }),
label
)
if (ok) {
if (config.modeToastLabel) {
sendUserToast(`${capitalize(mode)} ${config.modeToastLabel} ${path}`)
}
loadTriggers()
}
committed = ok
} catch (err) {
sendUserToast(`${config.modeError(modeVerb(mode))}: ${err.body}`, true)
loadTriggers()
}
return committed
}
async function deleteTrigger(row: TriggerW) {
const workspace = $operatingWorkspace ?? ''
if (triggerKind === 'postgres' || triggerKind === 'gcp' || triggerKind === 'azure') {
isDeleting = false
deleteA = false
deleteB = false
pendingDelete = row
return
}
if (!config.catchDelete) {
await config.remove({ workspace, path: row.path })
loadTriggers()
return
}
try {
await config.remove({ workspace, path: row.path })
if (config.deleteToastLabel) {
sendUserToast(`Successfully deleted ${config.deleteToastLabel}: ${row.path}`)
}
loadTriggers()
} catch (error) {
sendUserToast(error.body || error.message, true)
}
}
// Kinds whose trigger owns an external subscription (a replication slot and publication, a
// cloud subscription) confirm the delete, offering to drop those too.
let pendingDelete: TriggerW | undefined = $state(undefined)
let isDeleting = $state(false)
let deleteA = $state(false)
let deleteB = $state(false)
async function confirmPendingDelete() {
const row = pendingDelete
if (!row) return
const workspace = $operatingWorkspace ?? ''
isDeleting = true
const cleanups: (() => Promise<unknown>)[] = []
if (triggerKind === 'postgres') {
if (deleteA) {
cleanups.push(() =>
PostgresTriggerService.deletePostgresReplicationSlot({
workspace,
path: row.postgres_resource_path!,
requestBody: { name: row.replication_slot_name! }
})
)
}
if (deleteB) {
cleanups.push(() =>
PostgresTriggerService.deletePostgresPublication({
workspace,
path: row.postgres_resource_path!,
publication: row.publication_name!
})
)
}
} else if (triggerKind === 'gcp' && deleteA) {
const requestBody = { subscription_id: row.subscription_id!, project_id: row.project_id }
cleanups.push(() =>
row.gcp_resource_path
? GcpTriggerService.deleteGcpSubscription({
workspace,
path: row.gcp_resource_path,
requestBody
})
: GcpTriggerService.deleteGcpSubscriptionWithDefaultCredentials({
workspace,
requestBody
})
)
} else if (triggerKind === 'azure' && deleteA && row.subscription_name) {
cleanups.push(() =>
AzureTriggerService.deleteAzureSubscription({
workspace,
path: row.azure_resource_path!,
requestBody: {
azure_mode: row.azure_mode!,
scope_resource_id: row.scope_resource_id!,
topic_name: row.topic_name ?? undefined,
subscription_name: row.subscription_name!
}
})
)
}
for (const cleanup of cleanups) {
try {
sendUserToast((await cleanup()) as string)
} catch (error) {
isDeleting = false
sendUserToast(error.body || error.message, true)
return
}
}
const msg = (await config.remove({ workspace, path: row.path })) as string
sendUserToast(msg)
loadTriggers()
pendingDelete = undefined
}
// A host opens rows itself, and the document's hash is not this page's.
let hashHandled = false
$effect(() => {
if (hosted) return
if (!hashHandled && triggers.length > 0 && editor) {
let hash = $page.url.hash
if (hash.length > 1) {
let path = hash.slice(1)
let trigger = triggers.find((t) => t.path === path)
if (trigger) {
hashHandled = true
editor?.openEdit(path, trigger.is_flow)
}
}
}
})
let filteredItems: (TriggerW & { marked?: any })[] | undefined = $state([])
let filter = $state('')
let ownerFilter: string | undefined = $state(undefined)
let nbDisplayed = $state(15)
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
let selectedFilterKind = $state(
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
)
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
$effect(() => {
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
})
$effect(() => {
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
})
// Read before the first write below, which would otherwise replace what the location asked for.
function loadQueryFilters(search: string) {
const params = new URLSearchParams(search)
const queryFilterKind = params.get(TRIGGER_PATH_KIND_FILTER_SETTING)
const queryFilterUserFolders = params.get(FILTER_USER_FOLDER_SETTING_NAME)
if (queryFilterKind) selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
if (queryFilterUserFolders) filterUserFolders = queryFilterUserFolders == 'true'
}
untrack(() => loadQueryFilters(hosted ? hosted.search : window.location.search))
$effect(() => {
if (!hosted) return
const search = hosted.search
untrack(() => loadQueryFilters(search))
})
$effect(() => {
const kind = selectedFilterKind
const userFolders = String(filterUserFolders)
untrack(() => {
if (hosted) {
const params = new URLSearchParams(hosted.search)
params.set(TRIGGER_PATH_KIND_FILTER_SETTING, kind)
params.set(FILTER_USER_FOLDER_SETTING_NAME, userFolders)
const next = `?${params}`
if (next !== hosted.search) hosted.setSearch(next)
return
}
setQuery(TRIGGER_PATH_KIND_FILTER_SETTING, kind, window.location.hash || undefined).then(
() => {
setQuery(FILTER_USER_FOLDER_SETTING_NAME, userFolders, window.location.hash || undefined)
}
)
})
})
function filterItemsPathsBaseOnUserFilters(item: TriggerW) {
if ($operatingWorkspace == 'admins') return true
if (!filterUserFolders) return true
const path = selectedFilterKind === 'trigger' ? item.path : item.script_path
return !path.startsWith('u/') || path.startsWith('u/' + operatingUser.current?.username + '/')
}
const preFilteredItems = $derived(
triggers.filter(
(x) =>
(ownerFilter == undefined ||
(selectedFilterKind === 'trigger' ? x.path : x.script_path).startsWith(
ownerFilter + '/'
)) &&
filterItemsPathsBaseOnUserFilters(x)
)
)
let lastWorkspace = untrack(() => $operatingWorkspace)
$effect.pre(() => {
const workspace = $operatingWorkspace
if (workspace === lastWorkspace) return
lastWorkspace = workspace
ownerFilter = undefined
})
const owners = $derived(
Array.from(
new Set(
(filteredItems ?? [])
.map((x) => (selectedFilterKind === 'trigger' ? x.path : x.script_path))
.filter(Boolean)
.map((p) => p.split('/').slice(0, 2).join('/'))
)
).sort()
)
const items = $derived(filter !== '' ? filteredItems : preFilteredItems)
// A push delivery has no connection to report and no consumer to suspend: it is an endpoint.
function isLive(row: TriggerRow): boolean {
if (triggerKind === 'gcp') return row.delivery_type !== 'push'
if (triggerKind === 'azure') return row.azure_mode === 'namespace_pull'
return true
}
function copyUrl(row: TriggerRow): { label: string; value: string } | undefined {
switch (triggerKind) {
case 'http':
return {
label: 'Copy URL',
value: getHttpRoute(
'r',
row.route_path,
(row.workspaced_route ?? false) || globalHttpWorkspacedRoute,
row.workspace_id!
)
}
case 'gcp':
case 'azure':
return isLive(row)
? undefined
: {
label: 'Copy URL',
value: getHttpRoute(`${triggerKind}/w`, row.path, true, row.workspace_id!)
}
case 'email':
return { label: 'Copy email address', value: emailAddress(row) }
default:
return undefined
}
}
function emailAddress(row: TriggerRow) {
return getEmailAddress(
row.local_part,
row.workspaced_local_part ?? false,
row.workspace_id!,
emailDomain ?? ''
)
}
function websocketUrl(url: string) {
return url.startsWith('$script:')
? 'URL: ' + url.replace('$script:', 'result of script ')
: url.startsWith('$flow:')
? 'URL: ' + url.replace('$flow:', 'result of flow ')
: url
}
const operatorBlocked = $derived(
config.operatorGate &&
!!operatingUser.current?.operator &&
!!$operatingWorkspace &&
!$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.triggers
)
const canCreate = $derived(!config.adminOnly || isAdmin)
</script>
{#snippet rowTitle(row: TriggerW & { marked?: any }, hasDraft: boolean | undefined)}
{@const marked = ['http', 'websocket', 'kafka', 'nats', 'email'].includes(triggerKind)
? row.marked
: undefined}
<div class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate">
{#if marked}
<span class="text-xs">
{@html marked}
</span>
{:else if triggerKind === 'http'}
{#if row.summary}
{row.summary}
{:else}
{row.http_method!.toUpperCase()}
/{isCloudHosted() || row.workspaced_route || globalHttpWorkspacedRoute
? row.workspace_id + '/' + row.route_path
: row.route_path}
{/if}
{:else if triggerKind === 'websocket'}
{websocketUrl(row.url!)}
{:else if triggerKind === 'kafka'}
{row.kafka_resource_path} - {row.topics!.join(', ')}
{:else if triggerKind === 'nats'}
{row.nats_resource_path} - {row.subjects!.join(', ')}
{:else if triggerKind === 'email'}
{emailAddress(row)}
{:else if triggerKind === 'gcp'}
{row.path} - {row.topic_id}
{:else if triggerKind === 'azure'}
{row.path} - {row.topic_name
? row.topic_name
: (row.scope_resource_id?.split('/')?.pop() ?? '')} ({row.azure_mode}, {row.subscription_name})
{:else}
{row.path}
{/if}{hasDraft ? '*' : ''}
</div>
{#if ['http', 'websocket', 'kafka', 'nats', 'email'].includes(triggerKind)}
<div class="text-secondary text-xs truncate text-left font-light">{row.path}</div>
{:else if triggerKind === 'postgres'}
<div class="text-secondary text-xs truncate text-left font-light">
{row.postgres_resource_path}
</div>
{/if}
<div class="text-secondary text-xs truncate text-left font-light">
{#if triggerKind === 'http' && row.static_asset_config}
file: {row.static_asset_config.s3}
{:else}
runnable: {row.script_path}
{/if}
</div>
{/snippet}
{#snippet statusDot(row: TriggerW)}
{@const status = config.status}
{#if status}
{@const ping = status.pings
? row.last_server_ping
? new Date(row.last_server_ping)
: undefined
: new Date()}
{@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000}
{@const effectiveMode = row.draft_only ? 'disabled' : row.mode}
{@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'}
<div class="w-10">
{#if (enabled && (!pinging || row.error)) || (!enabled && row.error) || (enabled && !row.server_id)}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle class="text-red-600 animate-ping absolute inline-flex fill-current" size={12} />
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div>
{#if enabled}
{#if !row.server_id}
{status.starting}
{:else}
{status.notConnected}{row.error ? ': ' + row.error : ''}
{/if}
{:else}
{status.disabled}: {row.error}
{/if}
</div>
{/snippet}
</Popover>
{:else if enabled}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle class="text-green-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div>
{status.connected}{status.shuttingDown && !row.server_id ? ' (shutting down...)' : ''}
</div>
{/snippet}
</Popover>
{/if}
</div>
{/if}
{/snippet}
<ConfirmationModal
open={pendingDelete !== undefined}
title={triggerKind === 'postgres'
? 'Delete Postgres trigger'
: triggerKind === 'gcp'
? 'Delete GCP Pub/Sub trigger'
: 'Delete Azure Event Grid trigger'}
confirmationText="Remove"
loading={isDeleting}
on:canceled={() => {
isDeleting = false
pendingDelete = undefined
}}
on:confirmed={confirmPendingDelete}
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to remove this trigger?</span>
{#if pendingDelete && triggerKind === 'postgres'}
<Toggle
options={{
left: `Delete the associated replication slot named: ${pendingDelete.replication_slot_name} ?`
}}
bind:checked={deleteA}
/>
<Toggle
options={{
left: `Delete the associated publication named: ${pendingDelete.publication_name} ?`
}}
bind:checked={deleteB}
/>
{:else if pendingDelete && triggerKind === 'gcp'}
<Toggle
options={{
left: `Delete subscription "${pendingDelete.subscription_id}" subscribed to topic "${pendingDelete.topic_id}"?`
}}
bind:checked={deleteA}
/>
{:else if pendingDelete?.subscription_name && triggerKind === 'azure'}
<Toggle
options={{
left: `Also delete Azure subscription "${pendingDelete.subscription_name}" on Azure?`
}}
bind:checked={deleteA}
/>
{/if}
</div>
</ConfirmationModal>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
{#await EDITORS[triggerKind]() then Editor}
<Editor.default onUpdate={loadTriggers} bind:this={editor} />
{/await}
{#if triggerKind === 'http'}
{#await import('./http/RoutesGenerator.svelte') then Generator}
<Generator.default closeFn={loadTriggers} bind:this={routesGenerator} />
{/await}
{#await import('./http/OpenAPISpecGenerator.svelte') then Generator}
<Generator.default bind:this={openAPISpecGenerator} />
{/await}
{/if}
<SearchItems
{filter}
items={preFilteredItems}
bind:filteredItems
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
/>
{#if operatorBlocked}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{:else}
<CenteredPage>
<PageHeader
title={config.title}
tooltip={config.tooltip}
documentationLink={config.documentationLink}
>
<div class="flex flex-row gap-2">
{#if triggerKind === 'http'}
<Button
unifiedSize="md"
variant="default"
startIcon={{ icon: Plus }}
on:click={() => routesGenerator?.openDrawer()}
>
From OpenAPI spec
</Button>
<Button
unifiedSize="md"
variant="default"
startIcon={{ icon: Plus }}
on:click={() => openAPISpecGenerator?.openDrawer()}
>
To OpenAPI spec
</Button>
{/if}
{#if canCreate}
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => editor?.openNew(false)}
>
New&nbsp;{config.newLabel}
</Button>
{/if}
</div>
</PageHeader>
{#if config.cloudDisabled && isCloudHosted()}
<Alert title="Not compatible with multi-tenant cloud" type="warning">
{config.title} are disabled in the multi-tenant cloud.
</Alert>
<div class="py-4"></div>
{/if}
<div class="w-full h-full flex flex-col">
<div class="w-full pb-4 pt-6">
<input
type="text"
placeholder={config.searchPlaceholder}
bind:value={filter}
class="search-item"
/>
<div class="flex flex-row items-center gap-2 mt-2">
<div class="text-xs font-semibold text-emphasis shrink-0"> Filter by path of </div>
<ToggleButtonGroup bind:selected={selectedFilterKind}>
{#snippet children({ item })}
<ToggleButton value="trigger" label={config.filterLabel} icon={Icon} {item} />
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
{/snippet}
</ToggleButtonGroup>
</div>
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
<div class="flex flex-row items-center justify-end gap-4">
{#if operatingUser.current?.is_super_admin && operatingUser.current.username.includes('@')}
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
{:else if isAdmin}
<Toggle
size="xs"
bind:checked={filterUserFolders}
options={{ right: `Only u/${operatingUser.current?.username} and f/*` }}
/>
{/if}
</div>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !triggers?.length}
<EmptyState
icon={Icon}
title={config.empty.title}
description={config.empty.description}
action={canCreate
? {
label: config.empty.actionLabel,
icon: Plus,
onClick: () => editor?.openNew(false),
aiId: config.empty.aiId,
aiDescription: config.empty.aiDescription
}
: undefined}
/>
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as row (row.path)}
{@const {
path,
edited_by,
edited_at,
script_path,
is_flow,
extra_perms,
canWrite,
mode,
retry,
error_handler_path,
error_handler_args,
labels,
draft_only,
is_draft
} = row}
{@const hasDraft =
getLocalDraftHint($operatingWorkspace, config.draftKind, path) ?? is_draft}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
{@const effectiveMode = draft_only ? 'disabled' : mode}
{@const live = isLive(row)}
{@const copy = copyUrl(row)}
<div
class="bg-surface-tertiary hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-5 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
onclick={(e) => {
if (hosted) e.preventDefault()
openEdit(path, is_flow)
}}
class="min-w-0 grow hover:underline decoration-gray-400"
>
{@render rowTitle(row, hasDraft)}
</a>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
{#if triggerKind !== 'http' && triggerKind !== 'azure' && labels?.length}
{#each labels as label}
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
{/each}
{/if}
</div>
{#if live}
{@render statusDot(row)}
{/if}
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
<DraftBadge {draft_only} is_draft={hasDraft} />
{#if live}
<TriggerModeToggle
disabled={draft_only}
title={draft_only
? 'Draft only: deploy the trigger to enable it'
: hasDraft
? 'Enables/disables the deployed trigger; the draft is not affected'
: undefined}
onToggleMode={(newMode) => onToggleMode(path, newMode)}
triggerMode={effectiveMode}
includeModalConfig={{
triggerPath: path,
triggerKind,
runnableConfig: {
path: script_path,
kind: is_flow ? 'flow' : 'script',
retry,
errorHandlerPath: error_handler_path,
errorHandlerArgs: error_handler_args
}
}}
{canWrite}
hideToggleLabels
hideDropdown
/>
{/if}
</div>
<div class="flex gap-2 items-center justify-end">
{#if copy}
<Button
on:click={() => copyToClipboard(copy.value)}
variant="subtle"
unifiedSize="md"
startIcon={{ icon: ClipboardCopy }}
>
{copy.label}
</Button>
{/if}
<Button
on:click={() => openEdit(path, is_flow)}
unifiedSize="md"
startIcon={canWrite ? { icon: Pen } : { icon: Eye }}
variant="subtle"
>
{canWrite ? 'Edit' : 'View'}
</Button>
<Dropdown
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => openLink(href)
},
...(canWrite && !draft_only && mode !== 'suspended'
? [
{
displayName: 'Suspend job execution',
icon: Pause,
action: () => {
onToggleMode(path, 'suspended')
}
}
]
: []),
{
displayName: canWrite ? 'Edit' : 'View',
icon: canWrite ? Pen : Eye,
action: () => openEdit(path, is_flow)
},
...(isDeployable('trigger', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer?.openDrawer(path, 'trigger', {
triggers: { kind: config.deployKind }
})
}
}
]
: []),
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`
},
{
displayName: 'Permissions',
icon: Shield,
action: () => {
shareModal?.openDrawer(path, config.shareKind as any)
}
},
{
displayName: 'Delete',
type: 'delete',
icon: Trash,
disabled: !canWrite || (config.adminOnly && !isAdmin),
action: () => deleteTrigger(row)
}
]}
/>
</div>
</div>
<div class="w-full flex justify-end items-baseline">
<div
class="flex flex-wrap text-2xs font-normal text-secondary gap-1 items-center justify-end truncate pr-2"
>
{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}
<div class="truncate">{edited_by ? 'at ' : ''}{displayDate(edited_at)}</div>
</div>
</div>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
<span class="text-xs font-normal text-primary"
>{nbDisplayed} items out of {items.length}
<button class="ml-4 font-semibold text-emphasis" onclick={() => (nbDisplayed += 30)}
>load 30 more</button
></span
>
{/if}
</CenteredPage>
{/if}
<ShareModal
bind:this={shareModal}
on:change={() => {
loadTriggers()
}}
/>
@@ -2,7 +2,6 @@
import { untrack } from 'svelte'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -176,7 +175,6 @@
defaultConfig?: Record<string, any>,
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.amqp.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
@@ -2,7 +2,6 @@
import { Alert, Button } from '$lib/components/common'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -156,7 +155,6 @@
isFlow: boolean,
defaultValues?: Record<string, any>
) {
if (handOffPageDrawer(TRIGGER_PAGES.azure.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
drawerLoading = true
@@ -2,7 +2,6 @@
import { Button } from '$lib/components/common'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -143,7 +142,6 @@
defaultConfig?: Partial<NewEmailTrigger>,
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.email.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
drawerLoading = true
@@ -2,7 +2,6 @@
import { Alert, Button } from '$lib/components/common'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -167,7 +166,6 @@
defaultValues?: Record<string, any>,
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.gcp.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
drawerLoading = true
@@ -2,7 +2,6 @@
import { Button } from '$lib/components/common'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -274,7 +273,6 @@
isFlow: boolean,
defaultConfig?: Partial<NewHttpTrigger>
) {
if (handOffPageDrawer(TRIGGER_PAGES.http.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
drawerLoading = true
@@ -16,16 +16,20 @@
import RouteEditor from './RouteEditor.svelte'
import { generateHttpTriggerFromOpenApi, type Source } from './utils'
import { isCloudHosted } from '$lib/cloud'
import { usedTriggerKinds, userStore } from '$lib/stores'
import { usedTriggerKinds } from '$lib/stores'
import FileInput from '../../common/fileInput/FileInput.svelte'
import { emptyStringTrimmed, sendUserToast } from '$lib/utils'
import FolderPicker from '../../FolderPicker.svelte'
import Required from '$lib/components/Required.svelte'
import { Drawer, DrawerContent } from '$lib/components/common'
import { get } from 'svelte/store'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
type Props = {
closeFn: () => Promise<void>
@@ -137,7 +141,9 @@
}
}
let userIsAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
let userIsAdmin = $derived(
operatingUser.current?.is_admin || operatingUser.current?.is_super_admin
)
async function generateHttpTrigger() {
try {
@@ -2,7 +2,6 @@
import { Alert, Button } from '$lib/components/common'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -178,7 +177,6 @@
defaultConfig?: Record<string, any>,
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.kafka.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
@@ -2,7 +2,6 @@
import { untrack } from 'svelte'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -173,7 +172,6 @@
defaultConfig?: Record<string, any>,
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.mqtt.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
@@ -2,7 +2,6 @@
import { Alert, Button } from '$lib/components/common'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -161,7 +160,6 @@
defaultConfig?: Record<string, any>,
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.nats.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
@@ -2,7 +2,6 @@
import { Alert, Button, TabContent } from '$lib/components/common'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -262,7 +261,6 @@
defaultConfig?: Record<string, any>,
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.postgres.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
@@ -2,7 +2,6 @@
import { Alert, Badge, Button, ButtonType, Tab, Tabs } from '$lib/components/common'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { SCHEDULES_PATH } from '$lib/components/sessions/previewPaths'
@@ -181,7 +180,6 @@
defaultCfg?: Record<string, any>,
fixedScriptPath_?: string
) {
if (handOffPageDrawer(SCHEDULES_PATH, ePath)) return
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
@@ -2,7 +2,6 @@
import { Alert, Button } from '$lib/components/common'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -156,7 +155,6 @@
defaultConfig?: Record<string, any>,
fixedScriptPath_?: string
) {
if (handOffPageDrawer(TRIGGER_PAGES.sqs.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
@@ -0,0 +1,506 @@
import {
AmqpTriggerService,
AzureTriggerService,
EmailTriggerService,
GcpTriggerService,
HttpTriggerService,
KafkaTriggerService,
MqttTriggerService,
NatsTriggerService,
PostgresTriggerService,
SqsTriggerService,
WebsocketTriggerService,
type AmqpTrigger,
type AzureTrigger,
type EmailTrigger,
type GcpTrigger,
type HttpTrigger,
type KafkaTrigger,
type MqttTrigger,
type NatsTrigger,
type PostgresTrigger,
type SqsTrigger,
type TriggerMode,
type UserDraftItemKind,
type WebsocketTrigger
} from '$lib/gen'
import type { TriggerKind as UsedTriggerKind } from '$lib/components/triggers'
import type { TriggerKind } from '$lib/components/sessions/previewPaths'
type AnyTrigger =
| HttpTrigger
| WebsocketTrigger
| PostgresTrigger
| KafkaTrigger
| NatsTrigger
| MqttTrigger
| AmqpTrigger
| SqsTrigger
| GcpTrigger
| AzureTrigger
| EmailTrigger
type KeysOfUnion<T> = T extends unknown ? keyof T : never
type FieldOf<T, K extends PropertyKey> = T extends unknown
? K extends keyof T
? T[K]
: never
: never
/** A row of any trigger list: the fields every kind shares, plus every kind's own as optional,
* so a field read by one kind's branch is still checked against the generated types. */
export type TriggerRow = Pick<
HttpTrigger,
'path' | 'script_path' | 'is_flow' | 'mode' | 'edited_by' | 'edited_at'
> & {
[K in Exclude<
KeysOfUnion<AnyTrigger>,
'path' | 'script_path' | 'is_flow' | 'mode' | 'edited_by' | 'edited_at'
>]?: FieldOf<AnyTrigger, K>
}
type Call<A> = (a: A) => Promise<unknown>
export type TriggerListConfig = {
/** Page title, and the kind's plural name in the cloud alert. */
title: string
tooltip: string
documentationLink?: string
/** Follows "New" on the create button. */
newLabel: string
searchPlaceholder: string
/** The "Filter by path of" choice naming the trigger itself. */
filterLabel: string
empty: {
title: string
description: string
actionLabel: string
aiId: string
aiDescription: string
}
/** Disabled in the multi-tenant cloud. */
cloudDisabled: boolean
/** Hidden from operators unless the workspace grants them triggers. */
operatorGate: boolean
/** Creating and deleting are for admins only. */
adminOnly: boolean
usedKind: UsedTriggerKind
deployKind: UsedTriggerKind
shareKind: string
draftKind: UserDraftItemKind
/** Kinds holding a live connection: polled, with a status dot. Each message is followed by
* the error or state it reports. */
status?: {
starting: string
notConnected: string
disabled: string
connected: string
/** Reports a connection whose server is going away. */
shuttingDown: boolean
/** The server stamps a ping; without one, a connected server counts as alive. */
pings: boolean
}
/** Mode changes run through the fork-conflict check, under this name. Kinds whose runtime
* address is workspace-prefixed skip it: a fork and its parent never collide. */
forkConflictLabel?: string
/** Toast a mode change that went through, as "<Mode> <label> <path>". */
modeToastLabel?: string
/** A failed mode change, followed by ": <error>". */
modeError: (verb: 'enable' | 'disable' | 'suspend') => string
/** Report a delete through a toast, as "Successfully deleted <label>: <path>". */
deleteToastLabel?: string
/** Report a failed delete through a toast. */
catchDelete: boolean
list: (a: { workspace: string; includeDraftOnly?: boolean }) => Promise<TriggerRow[]>
setMode: Call<{
workspace: string
path: string
requestBody: { mode: TriggerMode; force?: boolean }
}>
remove: Call<{ workspace: string; path: string }>
}
const brokerTooltip = (name: string) =>
`Windmill can connect to an ${name} broker, subscribe to specific topics, and trigger scripts or flows based on those topics.`
const consumerStatus = {
starting: 'Consumer is starting...',
notConnected: 'Consumer is not connected',
disabled: 'Consumer was disabled because of an error',
connected: 'Consumer is connected',
shuttingDown: false,
pings: true
}
export const TRIGGER_LIST_CONFIG: Record<TriggerKind, TriggerListConfig> = {
http: {
title: 'Custom HTTP routes',
tooltip:
'Every script and flow already has a canonical HTTP API endpoint/webhook attached to it, this is to create additional parametrizable ones.',
documentationLink: 'https://www.windmill.dev/docs/core_concepts/http_routing',
newLabel: 'route',
searchPlaceholder: 'Search routes',
filterLabel: 'Route',
empty: {
title: 'No custom HTTP routes yet',
description:
'Every script and flow already has a canonical HTTP endpoint attached to it. These are additional parametrizable ones.',
actionLabel: 'Add a route',
aiId: 'routes-empty-add',
aiDescription: 'Add route'
},
cloudDisabled: false,
operatorGate: true,
adminOnly: false,
usedKind: 'routes',
deployKind: 'routes',
shareKind: 'http_trigger',
draftKind: 'trigger_http',
modeError: (verb) => `Cannot ${verb} http trigger`,
deleteToastLabel: 'HTTP route',
catchDelete: true,
list: (a) => HttpTriggerService.listHttpTriggers(a),
setMode: (a) => HttpTriggerService.setHttpTriggerMode(a),
remove: (a) => HttpTriggerService.deleteHttpTrigger(a)
},
websocket: {
title: 'WebSocket triggers',
tooltip: 'Windmill can listen to WebSocket events and trigger scripts or flows based on them.',
newLabel: 'WebSocket trigger',
searchPlaceholder: 'Search WS triggers',
filterLabel: 'WS trigger',
empty: {
title: 'No WebSocket triggers yet',
description:
'Windmill can listen to WebSocket events and trigger scripts or flows based on them.',
actionLabel: 'Add a WebSocket trigger',
aiId: 'websocket-triggers-empty-add',
aiDescription: 'Add WebSocket trigger'
},
cloudDisabled: true,
operatorGate: false,
adminOnly: false,
usedKind: 'websockets',
deployKind: 'websockets',
shareKind: 'websocket_trigger',
draftKind: 'trigger_websocket',
status: {
starting: 'WebSocket is starting...',
notConnected: 'WebSocket is not connected',
disabled: 'WebSocket was disabled because of an error',
connected: 'WebSocket is connected',
shuttingDown: true,
pings: true
},
forkConflictLabel: 'websocket trigger',
modeToastLabel: 'websocket trigger',
modeError: (verb) => `Cannot ${verb} websocket trigger`,
catchDelete: false,
list: (a) => WebsocketTriggerService.listWebsocketTriggers(a),
setMode: (a) => WebsocketTriggerService.setWebsocketTriggerMode(a),
remove: (a) => WebsocketTriggerService.deleteWebsocketTrigger(a)
},
postgres: {
title: 'Postgres triggers',
tooltip:
'Windmill enables real-time responsiveness by listening to specific database transactions—such as inserts, updates, and deletes—and automatically triggering scripts or workflows in response.',
newLabel: 'Postgres trigger',
searchPlaceholder: 'Search Postgres triggers',
filterLabel: 'Postgres trigger',
empty: {
title: 'No Postgres triggers yet',
description:
'Windmill can listen to database transactions — inserts, updates and deletes — and trigger scripts or flows in response.',
actionLabel: 'Add a Postgres trigger',
aiId: 'postgres-triggers-empty-add',
aiDescription: 'Add Postgres trigger'
},
cloudDisabled: true,
operatorGate: false,
adminOnly: false,
usedKind: 'postgres',
deployKind: 'postgres',
shareKind: 'postgres_trigger',
draftKind: 'trigger_postgres',
status: {
starting: 'Postgres trigger is starting...',
notConnected: 'Could not connect to database',
disabled: 'Disabled because of an error',
connected: 'Connected to database',
shuttingDown: true,
pings: false
},
forkConflictLabel: 'postgres trigger',
modeToastLabel: 'postgres trigger',
modeError: () => 'Cannot change postgres trigger mode',
catchDelete: false,
list: (a) => PostgresTriggerService.listPostgresTriggers(a),
setMode: (a) => PostgresTriggerService.setPostgresTriggerMode(a),
remove: (a) => PostgresTriggerService.deletePostgresTrigger(a)
},
kafka: {
title: 'Kafka triggers',
tooltip: 'Windmill can consume kafka events and trigger scripts or flows based on them.',
newLabel: 'Kafka trigger',
searchPlaceholder: 'Search Kafka triggers',
filterLabel: 'Kafka trigger',
empty: {
title: 'No Kafka triggers yet',
description: 'Windmill can consume kafka events and trigger scripts or flows based on them.',
actionLabel: 'Add a Kafka trigger',
aiId: 'kafka-triggers-empty-add',
aiDescription: 'Add Kafka trigger'
},
cloudDisabled: true,
operatorGate: true,
adminOnly: false,
usedKind: 'kafka',
deployKind: 'kafka',
shareKind: 'kafka_trigger',
draftKind: 'trigger_kafka',
status: consumerStatus,
forkConflictLabel: 'Kafka trigger',
modeError: (verb) => `Cannot ${verb} Kafka trigger`,
catchDelete: false,
list: (a) => KafkaTriggerService.listKafkaTriggers(a),
setMode: (a) => KafkaTriggerService.setKafkaTriggerMode(a),
remove: (a) => KafkaTriggerService.deleteKafkaTrigger(a)
},
nats: {
title: 'NATS triggers',
tooltip: 'Windmill can consume NATS events and trigger scripts or flows based on them.',
newLabel: 'NATS trigger',
searchPlaceholder: 'Search NATS triggers',
filterLabel: 'NATS trigger',
empty: {
title: 'No NATS triggers yet',
description: 'Windmill can consume NATS events and trigger scripts or flows based on them.',
actionLabel: 'Add a NATS trigger',
aiId: 'nats-triggers-empty-add',
aiDescription: 'Add NATS trigger'
},
cloudDisabled: true,
operatorGate: true,
adminOnly: false,
usedKind: 'nats',
deployKind: 'nats',
shareKind: 'nats_trigger',
draftKind: 'trigger_nats',
status: consumerStatus,
forkConflictLabel: 'NATS trigger',
modeError: (verb) => `Cannot ${verb} NATS trigger`,
catchDelete: false,
list: (a) => NatsTriggerService.listNatsTriggers(a),
setMode: (a) => NatsTriggerService.setNatsTriggerMode(a),
remove: (a) => NatsTriggerService.deleteNatsTrigger(a)
},
sqs: {
title: 'SQS triggers',
tooltip: 'SQS trigger',
newLabel: 'SQS trigger',
searchPlaceholder: 'Search SQS triggers',
filterLabel: 'SQS trigger',
empty: {
title: 'No SQS triggers yet',
description:
'Windmill can consume messages from an SQS queue and trigger scripts or flows on each one.',
actionLabel: 'Add an SQS trigger',
aiId: 'sqs-triggers-empty-add',
aiDescription: 'Add SQS trigger'
},
cloudDisabled: true,
operatorGate: false,
adminOnly: false,
usedKind: 'sqs',
deployKind: 'sqs',
shareKind: 'sqs_trigger',
draftKind: 'trigger_sqs',
status: {
starting: 'SQS trigger is starting...',
notConnected: 'Could not connect to SQS',
disabled: 'Disabled because of an error',
connected: 'Connected to SQS',
shuttingDown: true,
pings: false
},
forkConflictLabel: 'SQS trigger',
modeError: (verb) => `Cannot ${verb} sqs trigger`,
catchDelete: true,
list: (a) => SqsTriggerService.listSqsTriggers(a),
setMode: (a) => SqsTriggerService.setSqsTriggerMode(a),
remove: (a) => SqsTriggerService.deleteSqsTrigger(a)
},
gcp: {
title: 'GCP Pub/Sub triggers',
tooltip: 'GCP Pub/Sub trigger',
newLabel: 'GCP Pub/Sub trigger',
searchPlaceholder: 'Search triggers',
filterLabel: 'GCP Pub/Sub trigger',
empty: {
title: 'No GCP Pub/Sub triggers yet',
description:
'Windmill can subscribe to a GCP Pub/Sub topic and trigger scripts or flows on each message.',
actionLabel: 'Add a GCP Pub/Sub trigger',
aiId: 'gcp-triggers-empty-add',
aiDescription: 'Add GCP Pub/Sub trigger'
},
cloudDisabled: true,
operatorGate: false,
adminOnly: false,
usedKind: 'gcp',
deployKind: 'gcp',
shareKind: 'gcp_trigger',
draftKind: 'trigger_gcp',
status: {
starting: 'GCP Pub/Sub trigger is starting...',
notConnected: 'Could not connect to GCP Pub/Sub',
disabled: 'Disabled because of an error',
connected: 'Connected to GCP Pub/Sub',
shuttingDown: true,
pings: false
},
forkConflictLabel: 'GCP Pub/Sub trigger',
modeError: (verb) => `Cannot ${verb} GCP Pub/Sub trigger`,
catchDelete: false,
list: (a) => GcpTriggerService.listGcpTriggers(a),
setMode: (a) => GcpTriggerService.setGcpTriggerMode(a),
remove: (a) => GcpTriggerService.deleteGcpTrigger(a)
},
azure: {
title: 'Azure Event Grid triggers',
tooltip: 'Azure Event Grid trigger',
newLabel: 'Azure Event Grid trigger',
searchPlaceholder: 'Search triggers',
filterLabel: 'Azure Event Grid trigger',
empty: {
title: 'No Azure Event Grid triggers yet',
description:
'Windmill can subscribe to an Azure Event Grid topic and trigger scripts or flows on each event.',
actionLabel: 'Add an Azure Event Grid trigger',
aiId: 'azure-triggers-empty-add',
aiDescription: 'Add Azure Event Grid trigger'
},
cloudDisabled: true,
operatorGate: false,
adminOnly: false,
usedKind: 'azure',
deployKind: 'azure',
shareKind: 'azure_trigger',
draftKind: 'trigger_azure',
status: {
starting: 'Azure Event Grid trigger is starting...',
notConnected: 'Could not connect to Azure Event Grid',
disabled: 'Disabled because of an error',
connected: 'Connected to Azure Event Grid',
shuttingDown: true,
pings: false
},
forkConflictLabel: 'Azure trigger',
modeError: (verb) => `Cannot ${verb} Azure Event Grid trigger`,
catchDelete: false,
list: (a) => AzureTriggerService.listAzureTriggers(a),
setMode: (a) => AzureTriggerService.setAzureTriggerMode(a),
remove: (a) => AzureTriggerService.deleteAzureTrigger(a)
},
mqtt: {
title: 'MQTT triggers',
tooltip: brokerTooltip('MQTT'),
newLabel: 'MQTT trigger',
searchPlaceholder: 'Search MQTT triggers',
filterLabel: 'MQTT trigger',
empty: {
title: 'No MQTT triggers yet',
description: brokerTooltip('MQTT'),
actionLabel: 'Add an MQTT trigger',
aiId: 'mqtt-triggers-empty-add',
aiDescription: 'Add MQTT trigger'
},
cloudDisabled: true,
operatorGate: false,
adminOnly: false,
usedKind: 'mqtt',
deployKind: 'mqtt',
shareKind: 'mqtt_trigger',
draftKind: 'trigger_mqtt',
status: {
starting: 'MQTT trigger is starting...',
notConnected: 'MQTT trigger is not connected',
disabled: 'MQTT trigger was disabled because of an error',
connected: 'MQTT trigger is connected',
shuttingDown: true,
pings: true
},
forkConflictLabel: 'MQTT trigger',
modeToastLabel: 'MQTT trigger',
modeError: (verb) => `Cannot ${verb} mqtt trigger`,
catchDelete: false,
list: (a) => MqttTriggerService.listMqttTriggers(a),
setMode: (a) => MqttTriggerService.setMqttTriggerMode(a),
remove: (a) => MqttTriggerService.deleteMqttTrigger(a)
},
amqp: {
title: 'AMQP triggers',
tooltip: brokerTooltip('AMQP'),
newLabel: 'AMQP trigger',
searchPlaceholder: 'Search AMQP triggers',
filterLabel: 'AMQP trigger',
empty: {
title: 'No AMQP triggers yet',
description: brokerTooltip('AMQP'),
actionLabel: 'Add an AMQP trigger',
aiId: 'amqp-triggers-empty-add',
aiDescription: 'Add AMQP trigger'
},
cloudDisabled: true,
operatorGate: false,
adminOnly: false,
usedKind: 'amqp',
deployKind: 'amqp',
shareKind: 'amqp_trigger',
draftKind: 'trigger_amqp',
status: {
starting: 'AMQP trigger is starting...',
notConnected: 'AMQP trigger is not connected',
disabled: 'AMQP trigger was disabled because of an error',
connected: 'AMQP trigger is connected',
shuttingDown: true,
pings: true
},
forkConflictLabel: 'AMQP trigger',
modeToastLabel: 'AMQP trigger',
modeError: (verb) => `Cannot ${verb} amqp trigger`,
catchDelete: false,
list: (a) => AmqpTriggerService.listAmqpTriggers(a),
setMode: (a) => AmqpTriggerService.setAmqpTriggerMode(a),
remove: (a) => AmqpTriggerService.deleteAmqpTrigger(a)
},
email: {
title: 'Custom email triggers',
tooltip:
'Every script and flow already has a canonical email trigger attached to it, this is to create additional parametrizable ones.',
documentationLink: 'https://www.windmill.dev/docs/advanced/email_triggers',
newLabel: 'email trigger',
searchPlaceholder: 'Search email triggers',
filterLabel: 'Email',
empty: {
title: 'No custom email triggers yet',
description:
'Every script and flow already has a canonical email trigger attached to it. These are additional parametrizable ones.',
actionLabel: 'Add an email trigger',
aiId: 'email-triggers-empty-add',
aiDescription: 'Add email trigger'
},
cloudDisabled: false,
operatorGate: true,
adminOnly: true,
usedKind: 'emails',
deployKind: 'routes',
shareKind: 'email_trigger',
draftKind: 'trigger_email',
modeError: (verb) => `Cannot ${verb} email trigger`,
deleteToastLabel: 'Email trigger',
catchDelete: true,
list: (a) => EmailTriggerService.listEmailTriggers(a),
setMode: (a) => EmailTriggerService.setEmailTriggerMode(a),
remove: (a) => EmailTriggerService.deleteEmailTrigger(a)
}
}
@@ -2,7 +2,6 @@
import { Alert, Button } from '$lib/components/common'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
@@ -202,7 +201,6 @@
isFlow: boolean,
defaultConfig?: Record<string, any>
) {
if (handOffPageDrawer(TRIGGER_PAGES.websocket.path, ePath)) return
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
@@ -0,0 +1,759 @@
<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import {
Alert,
Badge,
Button,
EmptyState,
Skeleton,
Tab,
TabFade,
Tabs
} from '$lib/components/common'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import { OauthService, VariableService, WorkspaceService } from '$lib/gen'
import ContextualVariableEditor from '$lib/components/ContextualVariableEditor.svelte'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import Dropdown from '$lib/components/DropdownV2.svelte'
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import Popover from '$lib/components/Popover.svelte'
import FilterSearchbar, {
hasActiveFilters,
useUrlSyncedFilterInstance
} from '$lib/components/FilterSearchbar.svelte'
import { buildVariablesFilterSchema } from '$lib/components/variables/variablesFilter'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Cell from '$lib/components/table/Cell.svelte'
import DataTable from '$lib/components/table/DataTable.svelte'
import Head from '$lib/components/table/Head.svelte'
import Row from '$lib/components/table/Row.svelte'
import TableSimple from '$lib/components/TableSimple.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import VariableEditor from '$lib/components/VariableEditor.svelte'
import type { ContextualVariable, ListableVariable, WorkspaceDeployUISettings } from '$lib/gen'
import { enterpriseLicense, userWorkspaces } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { canWrite, isOwner, truncate } from '$lib/utils'
import { isDeployable, ALL_DEPLOYABLE } from '$lib/utils_deployable'
import {
Plus,
FileUp,
Link,
Pen,
RefreshCw,
Shield,
Trash,
Building,
DollarSign,
EyeOff,
Circle,
SearchX
} from 'lucide-svelte'
import { untrack } from 'svelte'
import { page } from '$app/stores'
import { useHostedPage } from '$lib/components/hostedPage'
import {
useOperatingWorkspace,
useOperatingUser
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const hosted = useHostedPage()
function editVariable(path: string) {
if (hosted) hosted.openItem(path)
else variableEditor?.editVariable(path)
}
type ListableVariableW = ListableVariable & { canWrite: boolean }
let variables = $state(undefined) as ListableVariableW[] | undefined
let showCreateButtons = $state(false)
// Collect unique values for filter autocomplete
let allPaths: string[] = $state([])
let allOwners: string[] = $state([])
let allLabels: string[] = $state([])
// FilterSearchbar setup
let userFoldersFilterType = $derived(
operatingUser.current?.is_super_admin && operatingUser.current.username.includes('@')
? 'only f/*'
: operatingUser.current?.is_admin || operatingUser.current?.is_super_admin
? 'u/username and f/*'
: undefined
)
let variablesFilterSchema = $derived(
buildVariablesFilterSchema({
paths: allPaths,
owners: allOwners,
labels: allLabels,
showUserFoldersFilter: userFoldersFilterType !== undefined,
userFoldersLabel:
userFoldersFilterType === 'only f/*'
? 'Only f/*'
: `Only u/${operatingUser.current?.username} and f/*`
})
)
let filters = useUrlSyncedFilterInstance(untrack(() => variablesFilterSchema))
// A link can carry the user folders filter to someone it is not offered to, where it would sit
// set with no chip to clear it. Dropped only once a user is actually known: `resolved` holds for
// the navigation workspace even while its user is still loading.
$effect(() => {
if (!operatingUser.resolved($operatingWorkspace) || !operatingUser.current) return
if (userFoldersFilterType !== undefined) return
if (!filters.val.user_folders_only) return
untrack(() => {
const { user_folders_only: _, ...rest } = filters.val
filters.val = rest
})
})
let itemFolders = $derived(
Array.from(
new Set(
(variables ?? [])
.map((x) => x.path.split('/').slice(0, 2).join('/'))
.filter((x) => x.startsWith('f/'))
)
)
.sort()
.map((f) => f.replace(/^f\//, ''))
)
let folderPresets = $derived([
...itemFolders.map((f) => ({ name: `f/${f}`, value: `path_start:\\ f/${f}/` })),
...allLabels.map((l) => ({ name: l, value: `label:\\ ${l}` })),
...(!variablesFilterSchema.user_folders_only.hidden
? [
{
name: variablesFilterSchema.user_folders_only.label ?? '?',
value: 'user_folders_only:\\ true'
}
]
: [])
])
let contextualVariables: ContextualVariable[] = $state([])
let shareModal: ShareModal | undefined = $state()
let variableEditor: VariableEditor | undefined = $state()
let contextualVariableEditor: ContextualVariableEditor | undefined = $state()
let loading = $state({
contextual: true
})
let deleteConfirmedCallback: (() => void) | undefined = $state(undefined)
let deleteIsLinked = $state(false)
let open = $derived(Boolean(deleteConfirmedCallback))
// Filter variables client-side for user folder filtering (admin feature)
let filteredItems = $derived.by(() => {
let items = variables
if (filters.val.user_folders_only && items) {
items = items.filter((item) => {
if (userFoldersFilterType === 'only f/*') return item.path.startsWith('f/')
if (userFoldersFilterType === 'u/username and f/*')
return (
item.path.startsWith('f/') ||
item.path.startsWith(`u/${operatingUser.current?.username}/`)
)
return true
})
}
return items
})
// If relative, the dropdown is positioned relative to its button
async function loadVariables(): Promise<void> {
const currentFilters = filters.val
// Build API parameters from filters.
// `includeDraftOnly` surfaces per-user drafts at paths that have
// no deployed variable — appended server-side when no narrowing
// filter is set, so the user sees AI-agent-created drafts on
// the home page.
const apiParams: any = {
workspace: $operatingWorkspace!,
includeDraftOnly: true
}
if (currentFilters.path) {
apiParams.path = currentFilters.path
}
if (currentFilters.path_start) {
apiParams.pathStart = currentFilters.path_start
}
if (currentFilters.description) {
apiParams.description = currentFilters.description
}
if (currentFilters.value) {
apiParams.value = currentFilters.value
}
if (currentFilters.owner) {
apiParams.pathStart = currentFilters.owner
}
if (currentFilters._default_) {
apiParams.broadFilter = currentFilters._default_
}
if (currentFilters.label) {
apiParams.label = currentFilters.label
}
const result = (await VariableService.listVariable(apiParams)).map((x) => {
return {
canWrite:
canWrite(x.path, x.extra_perms!, operatingUser.current) &&
x.workspace_id == $operatingWorkspace,
...x
}
})
// Extract unique values for autocomplete
allPaths = Array.from(new Set(result.map((x) => x.path))).sort()
allOwners = Array.from(
new Set(result.map((x) => x.path.split('/').slice(0, 2).join('/')))
).sort()
allLabels = Array.from(
new Set(result.flatMap((x) => [...(x.labels ?? []), ...(x.inherited_labels ?? [])]))
).sort()
variables = result
}
// Reload variables when filters change
$effect(() => {
filters.val
if ($operatingWorkspace) {
untrack(() => loadVariables())
}
})
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getPublicSettings({ workspace: $operatingWorkspace! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
async function loadContextualVariables(): Promise<void> {
contextualVariables = await VariableService.listContextualVariables({
workspace: $operatingWorkspace!
})
loading.contextual = false
}
async function deleteVariable(path: string, account?: number): Promise<void> {
if (account) {
OauthService.disconnectAccount({ workspace: $operatingWorkspace!, id: account })
}
await VariableService.deleteVariable({ workspace: $operatingWorkspace!, path })
loadVariables()
sendUserToast(`Variable ${path} was deleted`)
}
$effect(() => {
if ($operatingWorkspace && operatingUser.current) {
untrack(() => {
loadVariables()
loadContextualVariables()
})
}
})
let tab: 'workspace' | 'contextual' = $state('workspace')
let activeFilters = $derived(hasActiveFilters(filters.val))
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
async function deleteContextualVariable(row: { name: string }) {
await WorkspaceService.setEnvironmentVariable({
workspace: $operatingWorkspace!,
requestBody: {
name: row.name,
value: undefined
}
})
loadContextualVariables()
sendUserToast(
`Custom contextual variable ${row.name} was deleted. It may take up to a few minutes to update.`
)
setTimeout(() => {
loadContextualVariables()
}, 5000)
}
// Deep link: #<path> opens that variable's edit drawer. Reactive rather than
// onMount so a hash change on the already-mounted page (e.g. the AI session
// preview re-pointing its tab) opens the drawer too. Row links pre-set
// handledHash: their onclick already opens the drawer.
let handledHash = ''
$effect(() => {
// A host opens rows itself, and the document's hash is not this page's.
if (hosted) return
const hash = $page.url.hash
// Only item paths are drawer targets: the same hash also carries global
// drawers like #superadmin-settings, which must not be looked up as a variable.
if (!/^#[ufg]\//.test(hash)) {
// Navigating away from a drawer target must clear the tracker, or
// re-targeting the same item later would be skipped as already handled.
handledHash = ''
return
}
if (hash === handledHash || !variableEditor) return
handledHash = hash
variableEditor.editVariable(hash.slice(1))
})
</script>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.variables}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{:else}
<CenteredPage>
<PageHeader
title="Variables"
tooltip="Save and permission strings to be reused in Scripts and Flows."
documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets"
>
{#if showCreateButtons}
<div class="flex flex-row justify-end">
{#if tab == 'contextual' && (operatingUser.current?.is_admin || operatingUser.current?.is_super_admin)}
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => contextualVariableEditor?.initNew()}
>
New&nbsp;contextual&nbsp;variable
</Button>
{:else}
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => variableEditor?.initNew()}
>
New&nbsp;variable
</Button>
{/if}
</div>
{/if}
</PageHeader>
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
<VariableEditor
bind:this={variableEditor}
workspace={$operatingWorkspace}
on:create={loadVariables}
/>
<ContextualVariableEditor
bind:this={contextualVariableEditor}
on:update={loadContextualVariables}
/>
<ShareModal
bind:this={shareModal}
on:change={() => {
loadVariables()
}}
/>
<div class="flex gap-2 justify-between items-center">
<Tabs bind:selected={tab}>
<Tab value="workspace" label="Workspace" icon={Building} />
<Tab value="contextual" label="Contextual" icon={DollarSign}>
{#snippet extra()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets#contextual-variables"
>
Contextual variables are passed as environment variables when running a script and
depends on the execution context.
</Tooltip>
{/snippet}
</Tab>
</Tabs>
{#if tab == 'workspace'}
<FilterSearchbar
class="grow max-w-[26rem]"
schema={variablesFilterSchema}
bind:value={filters.val}
placeholder="Filter variables..."
presets={folderPresets}
/>
{/if}
</div>
<TabFade key={tab}>
{#if tab == 'workspace'}
<div class="relative overflow-x-auto pb-40 mt-4">
{#if !filteredItems}
<Skeleton layout={[0.5, [2], 1]} />
{#each new Array(3) as _}
<Skeleton layout={[[3.5], 0.5]} />
{/each}
{:else if filteredItems.length == 0}
{#if activeFilters}
<EmptyState
icon={SearchX}
title="No variables found"
description="No variable matches the current filters. Try clearing or widening them."
/>
{:else}
<EmptyState
icon={DollarSign}
title="No variables yet"
description="Variables store values and secrets that your scripts, flows and apps can read at runtime."
action={showCreateButtons
? {
label: 'Add a variable',
icon: Plus,
onClick: () => variableEditor?.initNew(),
aiId: 'variables-empty-add-variable',
aiDescription: 'Add variable'
}
: undefined}
/>
{/if}
{:else}
<DataTable size="xs">
<Head>
<tr>
<Cell head first class="!px-0" />
<Cell head>Path</Cell>
<Cell head>Value</Cell>
<Cell head>Description</Cell>
<Cell head>Status</Cell>
<Cell head last actions>Actions</Cell>
</tr>
</Head>
<tbody class="divide-y">
{#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft }}
{@const hasDraft =
getLocalDraftHint($operatingWorkspace, 'variable', path) ?? is_draft}
<Row>
<Cell class="!px-0 text-center w-12" first>
<SharedBadge {canWrite} extraPerms={extra_perms} />
</Cell>
<Cell>
<div class="flex items-center gap-2">
<a
class="break-all"
id="edit-{path}"
onclick={() => {
handledHash = `#${path}`
editVariable(path)
}}
href="#{path}"
>
{path}{hasDraft ? '*' : ''}
</a>
<DraftBadge {draft_only} is_draft={hasDraft} />
{#if labels?.length}
{#each labels as label}
<Badge
color="blue"
small
class="px-1"
title="Label: {label}"
clickable
onclick={() => {
const arr = (filters.val.label ?? '').split(',').filter(Boolean)
const idx = arr.indexOf(label)
if (idx >= 0) arr.splice(idx, 1)
else arr.push(label)
const newFilters = { ...filters.val }
if (arr.length) newFilters.label = arr.join(',')
else delete newFilters.label
filters.val = newFilters
}}>{label}</Badge
>
{/each}
{/if}
<InheritedLabels labels={inherited_labels} />
</div>
</Cell>
<Cell>
<span class="inline-flex flex-row items-center gap-2">
<div class="text-xs break-words">
{#if value}
{truncate(value, 20)}
{:else}
&lowast;&lowast;&lowast;&lowast;
{/if}
</div>
{#if is_secret}
<Popover notClickable>
<EyeOff size={12} />
{#snippet text()}
<span>This item is secret</span>
{/snippet}
</Popover>
{/if}
</span>
</Cell>
<Cell class="break-words">
<span class="text-xs text-primary">{truncate(description ?? '', 50)} </span>
</Cell>
<Cell class="text-center">
<div class="flex flex-row items-center gap-4">
{#if is_linked}
<Popover notClickable>
<Link size={16} />
{#snippet text()}
<div>
This variable is linked with a resource of the same path. They are
deleted and renamed together.
</div>
{/snippet}
</Popover>
{/if}
{#if account}
<Popover notClickable>
<RefreshCw size={16} />
{#snippet text()}
<div>
This OAuth token will be kept up-to-date in the background by
Windmill using its refresh token
</div>
{/snippet}
</Popover>
{/if}
{#if is_refreshed}
<div class="">
{#if refresh_error}
<Popover notClickable>
<!-- isolate: confine the ping indicator's z-50 to a local stacking context
so it can't paint over anything that scrolls past it -->
<div
class="relative inline-flex justify-center items-center w-4 h-4 isolate"
>
<Circle
class="text-red-600 animate-ping absolute z-50 w-4 h-4 fill-current"
size={12}
/>
<Circle
class="text-red-600 relative inline-flex fill-current "
size={12}
/>
</div>
{#snippet text()}
<div>
Latest exchange of the refresh token did not succeed. Error: {refresh_error}
</div>
{/snippet}
</Popover>
{:else if is_expired}
<Popover notClickable>
<Circle
class="text-yellow-600 animate-[pulse_5s_linear_infinite] fill-current"
size={12}
/>
{#snippet text()}
<div>
The access_token is expired, it will get renewed the next time
this variable is fetched or you can request is to be refreshed
in the dropdown on the right.
</div>
{/snippet}
</Popover>
{:else}
<Popover notClickable>
<Circle
class="text-green-600 animate-[pulse_5s_linear_infinite] fill-current"
size={12}
/>
{#snippet text()}
<div>
The variable was connected through OAuth and the token is not
expired.
</div>
{/snippet}
</Popover>
{/if}
</div>
{/if}
</div>
</Cell>
<Cell last actions shouldStopPropagation>
<Dropdown
items={() => {
let owner = isOwner(path, operatingUser.current, $operatingWorkspace)
return [
{
displayName: 'Edit',
icon: Pen,
action: () => editVariable(path),
disabled: !canWrite || !showCreateButtons
},
{
displayName: 'Delete',
icon: Trash,
type: 'delete',
action: (event) => {
if (event['shiftKey']) {
deleteVariable(path, account)
} else {
deleteIsLinked = is_linked ?? false
deleteConfirmedCallback = () => {
deleteVariable(path, account)
}
}
},
disabled: !owner || !showCreateButtons
},
...(!ws_specific &&
isDeployable(is_secret ? 'secret' : 'variable', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer?.openDrawer(path, 'variable')
}
}
]
: []),
{
displayName: 'Permissions',
action: () => {
shareModal?.openDrawer(path, 'variable')
},
icon: Shield
},
...(account != undefined
? [
{
displayName: 'Refresh token',
icon: RefreshCw,
action: async () => {
await OauthService.refreshToken({
workspace: $operatingWorkspace ?? '',
id: account ?? 0,
requestBody: {
path
}
})
sendUserToast('Token refreshed')
loadVariables()
}
}
]
: [])
]
}}
/>
</Cell>
</Row>
{/each}
</tbody>
</DataTable>
{/if}
</div>
{:else if tab == 'contextual'}
<div class="overflow-auto">
{#if loading.contextual}
<Skeleton layout={[0.5, [2], 1]} />
{#each new Array(8) as _}
<Skeleton layout={[[2.8], 0.5]} />
{/each}
{:else}
<PageHeader title="Custom contextual variables" primary={false} />
{#if contextualVariables.filter((x) => x.is_custom).length === 0}
<EmptyState
icon={DollarSign}
title="No custom contextual variables yet"
description="Custom contextual variables are passed as environment variables to every script run in this workspace."
action={showCreateButtons &&
(operatingUser.current?.is_admin || operatingUser.current?.is_super_admin)
? {
label: 'Add a contextual variable',
icon: Plus,
onClick: () => contextualVariableEditor?.initNew(),
aiId: 'variables-empty-add-contextual-variable',
aiDescription: 'Add contextual variable'
}
: undefined}
/>
{:else}
<TableSimple
headers={['Name', 'Value']}
data={contextualVariables.filter((x) => x.is_custom)}
keys={['name', 'value']}
getRowActions={operatingUser.current?.is_admin ||
operatingUser.current?.is_super_admin
? (row) => {
return [
{
displayName: 'Edit',
action: () => contextualVariableEditor?.editVariable(row.name, row.value)
},
{
displayName: 'Delete',
type: 'delete',
action: () => {
deleteContextualVariable(row)
}
}
]
}
: undefined}
/>
{/if}
<PageHeader title="Contextual variables" primary={false} />
<TableSimple
headers={['Name', 'Example of value', 'Description']}
data={contextualVariables.filter((x) => !x.is_custom)}
keys={['name', 'value', 'description']}
/>
{/if}
</div>
{/if}
</TabFade>
</CenteredPage>
{/if}
<ConfirmationModal
{open}
title="Remove variable"
confirmationText="Remove"
trashbin
on:canceled={() => {
deleteConfirmedCallback = undefined
}}
on:confirmed={() => {
if (deleteConfirmedCallback) {
deleteConfirmedCallback()
}
deleteConfirmedCallback = undefined
}}
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to remove this variable?</span>
{#if deleteIsLinked}
<Alert type="warning" title="Linked resource">
This variable is linked with a resource of the same path. The linked resource will also be
deleted.
</Alert>
{/if}
<Alert type="info" title="Bypass confirmation">
<div>
You can press
<Badge color="dark-gray">SHIFT</Badge>
while removing a variable to bypass confirmation.
</div>
</Alert>
</div>
</ConfirmationModal>
@@ -64,15 +64,14 @@ export function buildVariablesFilterSchema({
icon: Tag,
description: 'Filter by label (comma-separated for multiple)'
},
...(showUserFoldersFilter
? {
user_folders_only: {
type: 'boolean' as const,
label: userFoldersLabel || 'User folders only',
icon: Users,
description: 'Show only variables in user folders'
}
}
: {})
// The key stays for every role, hidden where the filter does not apply: the list's URL sync
// reads the key set once, before the acting user that gates it may have loaded.
user_folders_only: {
type: 'boolean' as const,
label: userFoldersLabel || 'User folders only',
icon: Users,
description: 'Show only variables in user folders',
hidden: !showUserFoldersFilter
}
} satisfies FilterSchemaRec
}
+17 -4
View File
@@ -3,6 +3,8 @@
import { z } from 'zod'
import { serializeParam } from '$lib/utils/serializeParam'
import { untrack } from 'svelte'
import { useHostedPage } from '$lib/components/hostedPage'
export type SearchParamsResult<S extends z.ZodType> =
S extends z.ZodObject<infer Shape>
@@ -58,12 +60,14 @@ function deserializeParam(raw: string, fieldSchema: z.ZodType): unknown {
export function useSearchParams<S extends z.ZodType>(schema: S): SearchParamsResult<S> {
const shape: Record<string, z.ZodType> = (schema as any).shape ?? {}
const keys = Object.keys(shape)
const hosted = useHostedPage()
const currentSearch = () => (hosted ? hosted.search : window.location.search)
// Reactive snapshot of search params - one $state cell per key
const values: Record<string, unknown> = $state(
Object.fromEntries(
keys.map((k) => {
const raw = new URLSearchParams(window.location.search).get(k)
const raw = new URLSearchParams(untrack(currentSearch)).get(k)
const parsed = raw != null ? deserializeParam(raw, shape[k]) : null
return [k, parsed]
})
@@ -71,7 +75,7 @@ export function useSearchParams<S extends z.ZodType>(schema: S): SearchParamsRes
)
function syncFromUrl() {
const sp = new URLSearchParams(window.location.search)
const sp = new URLSearchParams(currentSearch())
for (const k of keys) {
const raw = sp.get(k)
const parsed = raw != null ? deserializeParam(raw, shape[k]) : null
@@ -79,8 +83,13 @@ export function useSearchParams<S extends z.ZodType>(schema: S): SearchParamsRes
}
}
// Keep in sync when the user navigates back/forward
// Keep in sync when the user navigates back/forward, or the host re-points the page
$effect(() => {
if (hosted) {
hosted.search
untrack(syncFromUrl)
return
}
window.addEventListener('popstate', syncFromUrl)
return () => window.removeEventListener('popstate', syncFromUrl)
})
@@ -94,12 +103,16 @@ export function useSearchParams<S extends z.ZodType>(schema: S): SearchParamsRes
},
set(v: unknown) {
;(values as any)[key] = v
const sp = new URLSearchParams(window.location.search)
const sp = new URLSearchParams(untrack(currentSearch))
if (v == null) {
sp.delete(key)
} else {
sp.set(key, serializeParam(v))
}
if (hosted) {
hosted.setSearch(sp.size ? `?${sp}` : '')
return
}
const hash = window.location.hash
const newUrl = sp.toString()
? `${window.location.pathname}?${sp}${hash}`
@@ -90,7 +90,11 @@
import SessionModeSwitch from '$lib/components/sessions/SessionModeSwitch.svelte'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
import { copilotInfo } from '$lib/aiStore'
import { parsePreviewItemRoute } from '$lib/components/sessions/previewPaths'
import {
isPageItemListPath,
parsePreviewItemRoute,
stripBase
} from '$lib/components/sessions/previewPaths'
import { rememberNavRoute } from '$lib/components/sessions/sessionSwitch.svelte'
import { sessionState } from '$lib/components/sessions/sessionState.svelte'
import { restoreSessionBackups } from '$lib/components/sessions/sessionMirror.svelte'
@@ -431,6 +435,21 @@
} catch {}
return
}
// A list page mounts in process too, where its rows open as tabs of their own.
const listUrl = navigation.to?.url
if (listUrl && isPageItemListPath(stripBase(listUrl.pathname))) {
navigation.cancel()
const u = new URL(listUrl.href)
u.searchParams.delete('nomenubar')
u.searchParams.delete('workspace')
try {
window.parent.postMessage(
{ type: 'wm.session.openList', href: u.pathname + u.search + u.hash },
window.location.origin
)
} catch {}
return
}
const runTarget = previewRunTarget(navigation.to?.url)
if (runTarget) {
navigation.cancel()
@@ -1,570 +1,5 @@
<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import { run } from 'svelte/legacy'
import {
AmqpTriggerService,
WorkspaceService,
type AmqpTrigger,
type TriggerMode,
type WorkspaceDeployUISettings
} from '$lib/gen'
import {
canWrite,
capitalize,
displayDate,
getLocalSetting,
sendUserToast,
storeLocalSetting,
removeTriggerKindIfUnused
} from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { base } from '$app/paths'
import { page } from '$app/stores'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
import Dropdown from '$lib/components/DropdownV2.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { enterpriseLicense, usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { Code, Eye, Pen, Plus, Shield, Trash, Circle, FileUp, Pause } from 'lucide-svelte'
import { goto } from '$lib/navigation'
import SearchItems from '$lib/components/SearchItems.svelte'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import ListFilters from '$lib/components/home/ListFilters.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { setQuery } from '$lib/navigation'
import { onDestroy, onMount } from 'svelte'
import Popover from '$lib/components/Popover.svelte'
import { isCloudHosted } from '$lib/cloud'
import AmqpTriggerEditor from '$lib/components/triggers/amqp/AmqpTriggerEditor.svelte'
import AmqpIcon from '$lib/components/icons/AmqpIcon.svelte'
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
type TriggerM = AmqpTrigger & { canWrite: boolean }
let triggers: TriggerM[] = $state([])
let shareModal: ShareModal | undefined = $state()
let loading = $state(true)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
async function loadTriggers(): Promise<void> {
triggers = (
await AmqpTriggerService.listAmqpTriggers({
workspace: $workspaceStore!,
includeDraftOnly: true
})
).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'amqp', $usedTriggerKinds)
loading = false
}
let interval = setInterval(async () => {
try {
const newTriggers = await AmqpTriggerService.listAmqpTriggers({
workspace: $workspaceStore!
})
for (let i = 0; i < triggers.length; i++) {
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
if (newTrigger) {
triggers[i] = {
...triggers[i],
error: newTrigger.error,
last_server_ping: newTrigger.last_server_ping,
mode: newTrigger.mode,
server_id: newTrigger.server_id
}
}
}
} catch (err) {
console.error(err)
}
}, 5000)
onDestroy(() => {
clearInterval(interval)
})
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
let committed = false
try {
const ok = await withForkConflictRetry(
(force) =>
AmqpTriggerService.setAmqpTriggerMode({
path,
workspace: $workspaceStore!,
requestBody: { mode, force }
}),
'AMQP trigger'
)
if (ok) {
sendUserToast(`${capitalize(mode)} AMQP trigger ${path}`)
loadTriggers()
}
committed = ok
} catch (err) {
sendUserToast(
`Cannot ${mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend'} amqp trigger: ${err.body}`,
true
)
loadTriggers()
}
return committed
}
run(() => {
if ($workspaceStore && $userStore) {
loadTriggers()
}
})
let amqpTriggerEditor: AmqpTriggerEditor | undefined = $state()
let hashHandled = false
$effect(() => {
if (!hashHandled && triggers.length > 0 && amqpTriggerEditor) {
let hash = $page.url.hash
if (hash.length > 1) {
let path = hash.slice(1)
let trigger = triggers.find((t) => t.path === path)
if (trigger) {
hashHandled = true
amqpTriggerEditor?.openEdit(path, trigger.is_flow)
}
}
}
})
let filteredItems: (TriggerM & { marked?: any })[] | undefined = $state([])
let items: typeof filteredItems | undefined = $state([])
let preFilteredItems: typeof filteredItems | undefined = $state([])
let filter = $state('')
let ownerFilter: string | undefined = $state(undefined)
let nbDisplayed = $state(15)
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
let selectedFilterKind = $state(
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
)
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
run(() => {
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
})
run(() => {
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
})
function filterItemsPathsBaseOnUserFilters(
item: TriggerM,
selectedFilterKind: 'trigger' | 'script_flow',
filterUserFolders: boolean
) {
if ($workspaceStore == 'admins') return true
if (filterUserFolders) {
if (selectedFilterKind === 'trigger') {
return (
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
)
} else {
return (
!item.script_path.startsWith('u/') ||
item.script_path.startsWith('u/' + $userStore?.username + '/')
)
}
} else {
return true
}
}
run(() => {
preFilteredItems =
ownerFilter != undefined
? selectedFilterKind === 'trigger'
? triggers?.filter(
(x) =>
x.path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter(
(x) =>
x.script_path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter((x) =>
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
})
run(() => {
if ($workspaceStore) {
ownerFilter = undefined
}
})
let owners = $derived(
selectedFilterKind === 'trigger'
? Array.from(
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
: Array.from(
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
)
run(() => {
items = filter !== '' ? filteredItems : preFilteredItems
})
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
setQuery(
TRIGGER_PATH_KIND_FILTER_SETTING,
selectedFilterKind,
window.location.hash || undefined
).then(() => {
setQuery(
FILTER_USER_FOLDER_SETTING_NAME,
String(filterUserFolders),
window.location.hash || undefined
)
})
}
function loadQueryFilters() {
let url = new URL(window.location.href)
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
if (queryFilterKind) {
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
}
if (queryFilterUserFolders) {
filterUserFolders = queryFilterUserFolders == 'true'
}
}
onMount(() => {
loadQueryFilters()
})
run(() => {
updateQueryFilters(selectedFilterKind, filterUserFolders)
})
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
</script>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<AmqpTriggerEditor onUpdate={loadTriggers} bind:this={amqpTriggerEditor} />
<SearchItems
{filter}
items={preFilteredItems}
bind:filteredItems
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
/>
<CenteredPage>
<PageHeader
title="AMQP triggers"
tooltip="Windmill can connect to an AMQP broker, subscribe to specific topics, and trigger scripts or flows based on those topics."
>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => amqpTriggerEditor?.openNew(false)}
>
New&nbsp;AMQP trigger
</Button>
</PageHeader>
{#if isCloudHosted()}
<Alert title="Not compatible with multi-tenant cloud" type="warning">
AMQP triggers are disabled in the multi-tenant cloud.
</Alert>
<div class="py-4"></div>
{/if}
<div class="w-full h-full flex flex-col">
<div class="w-full pb-4 pt-6">
<input
type="text"
placeholder="Search AMQP triggers"
bind:value={filter}
class="search-item"
/>
<div class="flex flex-row items-center gap-2 mt-6">
<div class="text-sm shrink-0"> Filter by path of </div>
<ToggleButtonGroup bind:selected={selectedFilterKind}>
{#snippet children({ item })}
<ToggleButton value="trigger" label="AMQP trigger" icon={AmqpIcon} {item} />
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
{/snippet}
</ToggleButtonGroup>
</div>
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
<div class="flex flex-row items-center justify-end gap-4">
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
<Toggle
size="xs"
bind:checked={filterUserFolders}
options={{ right: `Only u/${$userStore.username} and f/*` }}
/>
{/if}
</div>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !triggers?.length}
<EmptyState
icon={AmqpIcon}
title="No AMQP triggers yet"
description="Windmill can connect to an AMQP broker, subscribe to specific topics, and trigger scripts or flows based on those topics."
action={{
label: 'Add an AMQP trigger',
icon: Plus,
onClick: () => amqpTriggerEditor?.openNew(false),
aiId: 'amqp-triggers-empty-add',
aiDescription: 'Add AMQP trigger'
}}
/>
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, extra_perms, canWrite, error, last_server_ping, server_id, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
{@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_amqp', path) ?? is_draft}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
{@const ping = last_server_ping ? new Date(last_server_ping) : undefined}
{@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000}
{@const effectiveMode = draft_only ? 'disabled' : mode}
{@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'}
<div
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-5 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
onclick={() => amqpTriggerEditor?.openEdit(path, is_flow)}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div class="text-emphasis font-semibold text-xs truncate text-left">
{path}{hasDraft ? '*' : ''}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
runnable: {script_path}
</div>
</a>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
{#if labels?.length}
{#each labels as label}
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
{/each}
{/if}
</div>
<div class="w-10">
{#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle
class="text-red-600 animate-ping absolute inline-flex fill-current"
size={12}
/>
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div>
{#if enabled}
{#if !server_id}
AMQP trigger is starting...
{:else}
AMQP trigger is not connected{error ? ': ' + error : ''}
{/if}
{:else}
AMQP trigger was disabled because of an error: {error}
{/if}
</div>
{/snippet}
</Popover>
{:else if enabled}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle class="text-green-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div>
AMQP trigger is connected{!server_id ? ' (shutting down...)' : ''}
</div>
{/snippet}
</Popover>
{/if}
</div>
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
<DraftBadge {draft_only} is_draft={hasDraft} />
<TriggerModeToggle
disabled={draft_only}
title={draft_only
? 'Draft only: deploy the trigger to enable it'
: hasDraft
? 'Enables/disables the deployed trigger; the draft is not affected'
: undefined}
onToggleMode={(newMode) => onToggleMode(path, newMode)}
triggerMode={effectiveMode}
includeModalConfig={{
triggerPath: path,
triggerKind: 'amqp',
runnableConfig: {
path: script_path,
kind: is_flow ? 'flow' : 'script',
retry,
errorHandlerPath: error_handler_path,
errorHandlerArgs: error_handler_args
}
}}
{canWrite}
hideToggleLabels
hideDropdown
/>
</div>
<div class="flex gap-2 items-center justify-end">
<Button
on:click={() => amqpTriggerEditor?.openEdit(path, is_flow)}
unifiedSize="md"
startIcon={canWrite
? { icon: Pen }
: {
icon: Eye
}}
variant="subtle"
>
{canWrite ? 'Edit' : 'View'}
</Button>
<Dropdown
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
goto(href)
}
},
...(canWrite && !draft_only && mode !== 'suspended'
? [
{
displayName: 'Suspend job execution',
icon: Pause,
action: () => {
onToggleMode(path, 'suspended')
}
}
]
: []),
{
displayName: canWrite ? 'Edit' : 'View',
icon: canWrite ? Pen : Eye,
action: () => {
amqpTriggerEditor?.openEdit(path, is_flow)
}
},
...(isDeployable('trigger', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer?.openDrawer(path, 'trigger', {
triggers: {
kind: 'amqp'
}
})
}
}
]
: []),
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`
},
{
displayName: 'Permissions',
icon: Shield,
action: () => {
shareModal?.openDrawer(path, 'amqp_trigger')
}
},
{
displayName: 'Delete',
type: 'delete',
icon: Trash,
disabled: !canWrite,
action: async () => {
await AmqpTriggerService.deleteAmqpTrigger({
workspace: $workspaceStore ?? '',
path
})
loadTriggers()
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
<div
class="flex flex-wrap text-[0.7em] text-primary gap-1 items-center justify-end truncate pr-2"
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
class="truncate">{edited_by ? 'the ' : ''}{displayDate(edited_at)}</div
></div
></div
>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
<span class="text-xs"
>{nbDisplayed} items out of {items.length}
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
>
{/if}
</CenteredPage>
<ShareModal
bind:this={shareModal}
on:change={() => {
loadTriggers()
}}
/>
<TriggerList triggerKind="amqp" />
@@ -1,668 +1,5 @@
<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import { run } from 'svelte/legacy'
import {
AzureTriggerService,
WorkspaceService,
type AzureTrigger,
type WorkspaceDeployUISettings,
type TriggerMode
} from '$lib/gen'
import {
canWrite,
copyToClipboard,
displayDate,
getLocalSetting,
sendUserToast,
storeLocalSetting,
removeTriggerKindIfUnused
} from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { base } from '$app/paths'
import { page } from '$app/stores'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Alert, Button, EmptyState, Skeleton } from '$lib/components/common'
import Dropdown from '$lib/components/DropdownV2.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { enterpriseLicense, usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import {
Code,
Eye,
Pen,
Plus,
Shield,
Trash,
Circle,
FileUp,
ClipboardCopy,
Pause
} from 'lucide-svelte'
import { goto } from '$lib/navigation'
import SearchItems from '$lib/components/SearchItems.svelte'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import ListFilters from '$lib/components/home/ListFilters.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { setQuery } from '$lib/navigation'
import { onDestroy, onMount } from 'svelte'
import Popover from '$lib/components/Popover.svelte'
import { isCloudHosted } from '$lib/cloud'
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import AzureTriggerEditor from '$lib/components/triggers/azure/AzureTriggerEditor.svelte'
import AzureIcon from '$lib/components/icons/AzureIcon.svelte'
import { getHttpRoute } from '$lib/components/triggers/http/utils'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
type TriggerD = AzureTrigger & { canWrite: boolean }
let triggers: TriggerD[] = $state([])
let shareModal: ShareModal | undefined = $state()
let loading = $state(true)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
let isDeleting = $state(false)
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
async function loadTriggers(): Promise<void> {
triggers = (
await AzureTriggerService.listAzureTriggers({
workspace: $workspaceStore!,
includeDraftOnly: true
})
).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'azure', $usedTriggerKinds)
loading = false
}
let interval = setInterval(async () => {
try {
const newTriggers = await AzureTriggerService.listAzureTriggers({
workspace: $workspaceStore!
})
for (let i = 0; i < triggers.length; i++) {
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
if (newTrigger) {
triggers[i] = {
...triggers[i],
error: newTrigger.error,
last_server_ping: newTrigger.last_server_ping,
mode: newTrigger.mode,
server_id: newTrigger.server_id
}
}
}
} catch (err) {
console.error(err)
}
}, 5000)
onDestroy(() => {
clearInterval(interval)
})
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
let committed = false
try {
const ok = await withForkConflictRetry(
(force) =>
AzureTriggerService.setAzureTriggerMode({
path,
workspace: $workspaceStore!,
requestBody: { mode, force }
}),
'Azure trigger'
)
committed = ok
if (ok) loadTriggers()
} catch (err) {
sendUserToast(
`Cannot ${mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend'} Azure Event Grid trigger: ${err.body}`,
true
)
loadTriggers()
}
return committed
}
run(() => {
if ($workspaceStore && $userStore) {
loadTriggers()
}
})
let azureTriggerEditor: AzureTriggerEditor | undefined = $state()
let hashHandled = false
$effect(() => {
if (!hashHandled && triggers.length > 0 && azureTriggerEditor) {
let hash = $page.url.hash
if (hash.length > 1) {
let path = hash.slice(1)
let trigger = triggers.find((t) => t.path === path)
if (trigger) {
hashHandled = true
azureTriggerEditor?.openEdit(path, trigger.is_flow)
}
}
}
})
let filteredItems: (TriggerD & { marked?: any })[] | undefined = $state([])
let items: typeof filteredItems | undefined = $state([])
let preFilteredItems: typeof filteredItems | undefined = $state([])
let filter = $state('')
let ownerFilter: string | undefined = $state(undefined)
let nbDisplayed = $state(15)
let deleteAzureTriggerCallback: (() => Promise<void>) | undefined = $state(undefined)
let deleteSubscription = $state(false)
let subscriptionToDelete: string | undefined = $state(undefined)
let subscriptionDeletePayload:
| {
azure_mode: AzureTrigger['azure_mode']
scope_resource_id: string
topic_name?: string
subscription_name: string
azure_resource_path: string
}
| undefined = $state(undefined)
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
let selectedFilterKind = $state(
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
)
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
run(() => {
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
})
run(() => {
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
})
function filterItemsPathsBaseOnUserFilters(
item: TriggerD,
selectedFilterKind: 'trigger' | 'script_flow',
filterUserFolders: boolean
) {
if ($workspaceStore == 'admins') return true
if (filterUserFolders) {
if (selectedFilterKind === 'trigger') {
return (
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
)
} else {
return (
!item.script_path.startsWith('u/') ||
item.script_path.startsWith('u/' + $userStore?.username + '/')
)
}
} else {
return true
}
}
run(() => {
preFilteredItems =
ownerFilter != undefined
? selectedFilterKind === 'trigger'
? triggers?.filter(
(x) =>
x.path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter(
(x) =>
x.script_path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter((x) =>
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
})
run(() => {
if ($workspaceStore) {
ownerFilter = undefined
}
})
let owners = $derived(
selectedFilterKind === 'trigger'
? Array.from(
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
: Array.from(
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
)
run(() => {
items = filter !== '' ? filteredItems : preFilteredItems
})
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
setQuery(
TRIGGER_PATH_KIND_FILTER_SETTING,
selectedFilterKind,
window.location.hash || undefined
).then(() => {
setQuery(
FILTER_USER_FOLDER_SETTING_NAME,
String(filterUserFolders),
window.location.hash || undefined
)
})
}
function loadQueryFilters() {
let url = new URL(window.location.href)
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
if (queryFilterKind) {
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
}
if (queryFilterUserFolders) {
filterUserFolders = queryFilterUserFolders == 'true'
}
}
onMount(() => {
loadQueryFilters()
})
run(() => {
updateQueryFilters(selectedFilterKind, filterUserFolders)
})
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
</script>
<ConfirmationModal
open={Boolean(deleteAzureTriggerCallback)}
title="Delete Azure Event Grid trigger"
confirmationText="Remove"
loading={isDeleting}
on:canceled={() => {
deleteAzureTriggerCallback = undefined
}}
on:confirmed={async () => {
if (deleteAzureTriggerCallback) {
isDeleting = true
if (deleteSubscription && subscriptionDeletePayload) {
try {
const msg = await AzureTriggerService.deleteAzureSubscription({
workspace: $workspaceStore ?? '',
path: subscriptionDeletePayload.azure_resource_path,
requestBody: {
azure_mode: subscriptionDeletePayload.azure_mode,
scope_resource_id: subscriptionDeletePayload.scope_resource_id,
topic_name: subscriptionDeletePayload.topic_name,
subscription_name: subscriptionDeletePayload.subscription_name
}
})
sendUserToast(msg)
} catch (error) {
isDeleting = false
sendUserToast(error.body || error.message, true)
return
}
}
await deleteAzureTriggerCallback()
}
deleteAzureTriggerCallback = undefined
subscriptionToDelete = undefined
subscriptionDeletePayload = undefined
}}
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to remove this trigger?</span>
{#if subscriptionToDelete}
<Toggle
options={{
left: `Also delete Azure subscription "${subscriptionToDelete}" on Azure?`
}}
bind:checked={deleteSubscription}
/>
{/if}
</div>
</ConfirmationModal>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<AzureTriggerEditor onUpdate={loadTriggers} bind:this={azureTriggerEditor} />
<SearchItems
{filter}
items={preFilteredItems}
bind:filteredItems
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
/>
<CenteredPage>
<PageHeader title="Azure Event Grid triggers" tooltip="Azure Event Grid trigger">
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => azureTriggerEditor?.openNew(false)}
>
New&nbsp;Azure Event Grid trigger
</Button>
</PageHeader>
{#if isCloudHosted()}
<Alert title="Not compatible with multi-tenant cloud" type="warning">
Azure Event Grid triggers are disabled in the multi-tenant cloud.
</Alert>
{/if}
<div class="w-full h-full flex flex-col">
<div class="w-full pb-4 pt-6">
<input type="text" placeholder="Search triggers" bind:value={filter} class="search-item" />
<div class="flex flex-row items-center gap-2 mt-6">
<div class="text-sm shrink-0"> Filter by path of </div>
<ToggleButtonGroup bind:selected={selectedFilterKind}>
{#snippet children({ item })}
<ToggleButton
value="trigger"
label="Azure Event Grid trigger"
icon={AzureIcon}
{item}
/>
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
{/snippet}
</ToggleButtonGroup>
</div>
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
<div class="flex flex-row items-center justify-end gap-4">
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
<Toggle
size="xs"
bind:checked={filterUserFolders}
options={{ right: `Only u/${$userStore.username} and f/*` }}
/>
{/if}
</div>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !triggers?.length}
<EmptyState
icon={AzureIcon}
title="No Azure Event Grid triggers yet"
description="Windmill can subscribe to an Azure Event Grid topic and trigger scripts or flows on each event."
action={{
label: 'Add an Azure Event Grid trigger',
icon: Plus,
onClick: () => azureTriggerEditor?.openNew(false),
aiId: 'azure-triggers-empty-add',
aiDescription: 'Add Azure Event Grid trigger'
}}
/>
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as { azure_resource_path, azure_mode, scope_resource_id, topic_name, subscription_name, workspace_id, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, retry, error_handler_path, error_handler_args, draft_only, is_draft } (path)}
{@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_azure', path) ?? is_draft}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
{@const ping = new Date()}
{@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000}
{@const effectiveMode = draft_only ? 'disabled' : mode}
{@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'}
{@const is_pull = azure_mode === 'namespace_pull'}
{@const topic_label = topic_name
? topic_name
: (scope_resource_id?.split('/')?.pop() ?? '')}
<div
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-5 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
onclick={() => azureTriggerEditor?.openEdit(path, is_flow)}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate">
{path} - {topic_label} ({azure_mode}, {subscription_name}){hasDraft ? '*' : ''}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
runnable: {script_path}
</div>
</a>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
</div>
{#if is_pull}
<div class="w-10">
{#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle
class="text-red-600 animate-ping absolute inline-flex fill-current"
size={12}
/>
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div>
{#if enabled}
{#if !server_id}
Azure Event Grid trigger is starting...
{:else}
Could not connect to Azure Event Grid{error ? ': ' + error : ''}
{/if}
{:else}
Disabled because of an error: {error}
{/if}
</div>
{/snippet}
</Popover>
{:else if enabled}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle
class="text-green-600 relative inline-flex fill-current"
size={12}
/>
</span>
{#snippet text()}
<div>
Connected to Azure Event Grid{!server_id
? ' (shutting down...)'
: ''}</div
>
{/snippet}
</Popover>
{/if}
</div>
{/if}
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
<DraftBadge {draft_only} is_draft={hasDraft} />
{#if is_pull}
<TriggerModeToggle
disabled={draft_only}
title={draft_only
? 'Draft only: deploy the trigger to enable it'
: hasDraft
? 'Enables/disables the deployed trigger; the draft is not affected'
: undefined}
onToggleMode={(newMode) => onToggleMode(path, newMode)}
triggerMode={effectiveMode}
includeModalConfig={{
triggerPath: path,
triggerKind: 'azure',
runnableConfig: {
path: script_path,
kind: is_flow ? 'flow' : 'script',
retry,
errorHandlerPath: error_handler_path,
errorHandlerArgs: error_handler_args
}
}}
{canWrite}
hideToggleLabels
hideDropdown
/>
{/if}
</div>
<div class="flex gap-2 items-center justify-end">
{#if !is_pull}
<Button
on:click={() =>
copyToClipboard(getHttpRoute('azure/w', path, true, workspace_id))}
variant="subtle"
size="xs"
startIcon={{ icon: ClipboardCopy }}
>
Copy URL
</Button>
{/if}
<Button
on:click={() => azureTriggerEditor?.openEdit(path, is_flow)}
size="xs"
startIcon={canWrite
? { icon: Pen }
: {
icon: Eye
}}
variant="subtle"
>
{canWrite ? 'Edit' : 'View'}
</Button>
<Dropdown
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
goto(href)
}
},
...(canWrite && !draft_only && mode !== 'suspended'
? [
{
displayName: 'Suspend job execution',
icon: Pause,
action: () => {
onToggleMode(path, 'suspended')
}
}
]
: []),
{
displayName: canWrite ? 'Edit' : 'View',
icon: canWrite ? Pen : Eye,
action: () => {
azureTriggerEditor?.openEdit(path, is_flow)
}
},
...(isDeployable('trigger', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer?.openDrawer(path, 'trigger', {
triggers: {
kind: 'azure'
}
})
}
}
]
: []),
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`
},
{
displayName: 'Permissions',
icon: Shield,
action: () => {
shareModal?.openDrawer(path, 'azure_trigger')
}
},
{
displayName: 'Delete',
type: 'delete',
icon: Trash,
disabled: !canWrite,
action: async () => {
isDeleting = false
deleteSubscription = false
subscriptionToDelete = subscription_name
subscriptionDeletePayload = {
azure_mode,
scope_resource_id,
topic_name: topic_name ?? undefined,
subscription_name,
azure_resource_path
}
deleteAzureTriggerCallback = async () => {
const message = await AzureTriggerService.deleteAzureTrigger({
workspace: $workspaceStore ?? '',
path
})
sendUserToast(message)
loadTriggers()
}
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
<div
class="flex flex-wrap text-[0.7em] text-primary gap-1 items-center justify-end truncate pr-2"
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
class="truncate">{edited_by ? 'the ' : ''}{displayDate(edited_at)}</div
></div
></div
>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
<span class="text-xs"
>{nbDisplayed} items out of {items.length}
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
>
{/if}
</CenteredPage>
<ShareModal
bind:this={shareModal}
on:change={() => {
loadTriggers()
}}
/>
<TriggerList triggerKind="azure" />
@@ -1,553 +1,5 @@
<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import { run } from 'svelte/legacy'
import {
EmailTriggerService,
WorkspaceService,
type EmailTrigger,
type TriggerMode,
type WorkspaceDeployUISettings
} from '$lib/gen'
import {
canWrite,
copyToClipboard,
displayDate,
getLocalSetting,
storeLocalSetting,
removeTriggerKindIfUnused,
sendUserToast
} from '$lib/utils'
import { base } from '$app/paths'
import { page } from '$app/stores'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
import Dropdown from '$lib/components/DropdownV2.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import {
userStore,
workspaceStore,
userWorkspaces,
enterpriseLicense,
usedTriggerKinds
} from '$lib/stores'
import {
Mail,
Code,
Eye,
Pen,
Plus,
Shield,
Trash,
FileUp,
ClipboardCopy,
Pause
} from 'lucide-svelte'
import { goto } from '$lib/navigation'
import SearchItems from '$lib/components/SearchItems.svelte'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import ListFilters from '$lib/components/home/ListFilters.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { setQuery } from '$lib/navigation'
import { onMount } from 'svelte'
import EmailTriggerEditor from '$lib/components/triggers/email/EmailTriggerEditor.svelte'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
import { getEmailAddress, getEmailDomain } from '$lib/components/triggers/email/utils'
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
type TriggerW = EmailTrigger & { canWrite: boolean }
let triggers: TriggerW[] = $state([])
let shareModal: ShareModal | undefined = $state()
let loading = $state(true)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
let emailDomain: string | null = $state(null)
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
async function loadTriggers(): Promise<void> {
triggers = (
await EmailTriggerService.listEmailTriggers({
workspace: $workspaceStore!,
includeDraftOnly: true
})
).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'emails', $usedTriggerKinds)
emailDomain = await getEmailDomain()
loading = false
}
run(() => {
if ($workspaceStore && $userStore) {
loadTriggers()
}
})
let emailTriggerEditor: EmailTriggerEditor | undefined = $state()
let hashHandled = false
$effect(() => {
if (!hashHandled && triggers.length > 0 && emailTriggerEditor) {
let hash = $page.url.hash
if (hash.length > 1) {
let path = hash.slice(1)
let trigger = triggers.find((t) => t.path === path)
if (trigger) {
hashHandled = true
emailTriggerEditor?.openEdit(path, trigger.is_flow)
}
}
}
})
let filteredItems: (TriggerW & { marked?: any })[] | undefined = $state([])
let items: typeof filteredItems | undefined = $state([])
let preFilteredItems: typeof filteredItems | undefined = $state([])
let filter = $state('')
let ownerFilter: string | undefined = $state(undefined)
let nbDisplayed = $state(15)
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
let selectedFilterKind = $state(
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
)
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
run(() => {
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
})
run(() => {
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
})
function filterItemsPathsBaseOnUserFilters(
item: TriggerW,
selectedFilterKind: 'trigger' | 'script_flow',
filterUserFolders: boolean
) {
if ($workspaceStore == 'admins') return true
if (filterUserFolders) {
if (selectedFilterKind === 'trigger') {
return (
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
)
} else {
return (
!item.script_path.startsWith('u/') ||
item.script_path.startsWith('u/' + $userStore?.username + '/')
)
}
} else {
return true
}
}
run(() => {
preFilteredItems =
ownerFilter != undefined
? selectedFilterKind === 'trigger'
? triggers?.filter(
(x) =>
x.path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter(
(x) =>
x.script_path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter((x) =>
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
})
run(() => {
if ($workspaceStore) {
ownerFilter = undefined
}
})
let owners = $derived(
selectedFilterKind === 'trigger'
? Array.from(
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
: Array.from(
new Set(
filteredItems
?.filter((x) => x.script_path)
.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? []
)
).sort()
)
run(() => {
items = filter !== '' ? filteredItems : preFilteredItems
})
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
setQuery(
TRIGGER_PATH_KIND_FILTER_SETTING,
selectedFilterKind,
window.location.hash || undefined
).then(() => {
setQuery(
FILTER_USER_FOLDER_SETTING_NAME,
String(filterUserFolders),
window.location.hash || undefined
)
})
}
function loadQueryFilters() {
let url = new URL(window.location.href)
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
if (queryFilterKind) {
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
}
if (queryFilterUserFolders) {
filterUserFolders = queryFilterUserFolders == 'true'
}
}
async function onToggleMode(path: string, mode: TriggerMode): Promise<void> {
try {
// Email addresses are always workspace-prefixed (clone filter
// excludes workspaced_local_part=false) — no fork-conflict warning.
await EmailTriggerService.setEmailTriggerMode({
path,
workspace: $workspaceStore!,
requestBody: { mode }
})
} catch (err) {
sendUserToast(
`Cannot ` +
(mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend') +
` email trigger: ${err.body}`,
true
)
} finally {
loadTriggers()
}
}
onMount(() => {
loadQueryFilters()
})
run(() => {
updateQueryFilters(selectedFilterKind, filterUserFolders)
})
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
</script>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<EmailTriggerEditor onUpdate={loadTriggers} bind:this={emailTriggerEditor} />
<SearchItems
{filter}
items={preFilteredItems}
bind:filteredItems
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
/>
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{:else}
<CenteredPage>
<PageHeader
title="Custom email triggers"
tooltip="Every script and flow already has a canonical email trigger attached to it, this is to create additional parametrizable ones."
documentationLink="https://www.windmill.dev/docs/advanced/email_triggers"
>
{#if $userStore?.is_admin || $userStore?.is_super_admin}
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => emailTriggerEditor?.openNew(false)}
>
New&nbsp;email trigger
</Button>
{/if}
</PageHeader>
<div class="w-full h-full flex flex-col">
<div class="w-full pb-4 pt-6">
<input
type="text"
placeholder="Search email triggers"
bind:value={filter}
class="search-item"
/>
<div class="flex flex-row items-center gap-2 mt-6">
<div class="text-sm shrink-0"> Filter by path of </div>
<ToggleButtonGroup bind:selected={selectedFilterKind}>
{#snippet children({ item })}
<ToggleButton small value="trigger" label="Email" icon={Mail} {item} />
<ToggleButton small value="script_flow" label="Script/Flow" icon={Code} {item} />
{/snippet}
</ToggleButtonGroup>
</div>
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
<div class="flex flex-row items-center justify-end gap-4">
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
<Toggle
size="xs"
bind:checked={filterUserFolders}
options={{ right: `Only u/${$userStore.username} and f/*` }}
/>
{/if}
</div>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !triggers?.length}
<EmptyState
icon={Mail}
title="No custom email triggers yet"
description="Every script and flow already has a canonical email trigger attached to it. These are additional parametrizable ones."
action={$userStore?.is_admin || $userStore?.is_super_admin
? {
label: 'Add an email trigger',
icon: Plus,
onClick: () => emailTriggerEditor?.openNew(false),
aiId: 'email-triggers-empty-add',
aiDescription: 'Add email trigger'
}
: undefined}
/>
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as { workspace_id, workspaced_local_part, path, edited_by, edited_at, script_path, is_flow, extra_perms, canWrite, marked, local_part, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
{@const hasDraft =
getLocalDraftHint($workspaceStore, 'trigger_email', path) ?? is_draft}
{@const effectiveMode = draft_only ? 'disabled' : mode}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
{@const emailAddress = getEmailAddress(
local_part,
workspaced_local_part ?? false,
workspace_id,
emailDomain ?? ''
)}
<div
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-5 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
onclick={() => emailTriggerEditor?.openEdit(path, is_flow)}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div
class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate"
>
{#if marked}
<span class="text-xs">
{@html marked}
</span>
{:else}
{emailAddress}
{/if}{hasDraft ? '*' : ''}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
{path}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
runnable: {script_path}
</div>
</a>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
{#if labels?.length}
{#each labels as label}
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
{/each}
{/if}
</div>
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
<DraftBadge {draft_only} is_draft={hasDraft} />
<TriggerModeToggle
disabled={draft_only}
title={draft_only
? 'Draft only: deploy the trigger to enable it'
: hasDraft
? 'Enables/disables the deployed trigger; the draft is not affected'
: undefined}
onToggleMode={(newMode) => onToggleMode(path, newMode)}
triggerMode={effectiveMode}
includeModalConfig={{
triggerPath: path,
triggerKind: 'email',
runnableConfig: {
path: script_path,
kind: is_flow ? 'flow' : 'script',
retry,
errorHandlerPath: error_handler_path,
errorHandlerArgs: error_handler_args
}
}}
{canWrite}
hideToggleLabels
hideDropdown
/>
</div>
<div class="flex gap-2 items-center justify-end">
<Button
on:click={() => copyToClipboard(emailAddress)}
variant="subtle"
unifiedSize="md"
startIcon={{ icon: ClipboardCopy }}
>
Copy email address
</Button>
<Button
on:click={() => emailTriggerEditor?.openEdit(path, is_flow)}
unifiedSize="md"
startIcon={canWrite
? { icon: Pen }
: {
icon: Eye
}}
variant="subtle"
>
{canWrite ? 'Edit' : 'View'}
</Button>
<Dropdown
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
goto(href)
}
},
...(canWrite && !draft_only && mode !== 'suspended'
? [
{
displayName: 'Suspend job execution',
icon: Pause,
action: () => {
onToggleMode(path, 'suspended')
}
}
]
: []),
{
displayName: canWrite ? 'Edit' : 'View',
icon: canWrite ? Pen : Eye,
action: () => {
emailTriggerEditor?.openEdit(path, is_flow)
}
},
...(isDeployable('trigger', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer?.openDrawer(path, 'trigger', {
triggers: {
kind: 'routes'
}
})
}
}
]
: []),
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`
},
{
displayName: 'Permissions',
icon: Shield,
action: () => {
shareModal?.openDrawer(path, 'email_trigger')
}
},
{
displayName: 'Delete',
type: 'delete',
icon: Trash,
disabled:
!canWrite || !($userStore?.is_admin || $userStore?.is_super_admin),
action: async () => {
try {
await EmailTriggerService.deleteEmailTrigger({
workspace: $workspaceStore ?? '',
path
})
sendUserToast(`Successfully deleted Email trigger: ${path}`)
loadTriggers()
} catch (error) {
sendUserToast(error.body || error.message, true)
}
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
<div
class="flex flex-wrap text-[0.7em] text-primary gap-1 items-center justify-end truncate pr-2"
>
{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}
<div class="truncate">{edited_by ? 'at ' : ''}{displayDate(edited_at)}</div>
</div>
</div>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
<span class="text-xs"
>{nbDisplayed} items out of {items.length}
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
>
{/if}
</CenteredPage>
{/if}
<ShareModal
bind:this={shareModal}
on:change={() => {
loadTriggers()
}}
/>
<TriggerList triggerKind="email" />
@@ -1,654 +1,5 @@
<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import { run } from 'svelte/legacy'
import {
GcpTriggerService,
WorkspaceService,
type GcpTrigger,
type WorkspaceDeployUISettings,
type TriggerMode
} from '$lib/gen'
import {
canWrite,
copyToClipboard,
displayDate,
getLocalSetting,
sendUserToast,
storeLocalSetting,
removeTriggerKindIfUnused
} from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { base } from '$app/paths'
import { page } from '$app/stores'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
import Dropdown from '$lib/components/DropdownV2.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { enterpriseLicense, usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import {
Code,
Eye,
Pen,
Plus,
Shield,
Trash,
Circle,
FileUp,
ClipboardCopy,
Pause
} from 'lucide-svelte'
import { goto } from '$lib/navigation'
import SearchItems from '$lib/components/SearchItems.svelte'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import ListFilters from '$lib/components/home/ListFilters.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { setQuery } from '$lib/navigation'
import { onDestroy, onMount } from 'svelte'
import Popover from '$lib/components/Popover.svelte'
import { isCloudHosted } from '$lib/cloud'
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import GcpTriggerEditor from '$lib/components/triggers/gcp/GcpTriggerEditor.svelte'
import GoogleCloudIcon from '$lib/components/icons/GoogleCloudIcon.svelte'
import { getHttpRoute } from '$lib/components/triggers/http/utils'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
type TriggerD = GcpTrigger & { canWrite: boolean }
let triggers: TriggerD[] = $state([])
let shareModal: ShareModal | undefined = $state()
let loading = $state(true)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
let isDeleting = $state(false)
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
async function loadTriggers(): Promise<void> {
triggers = (
await GcpTriggerService.listGcpTriggers({
workspace: $workspaceStore!,
includeDraftOnly: true
})
).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'gcp', $usedTriggerKinds)
loading = false
}
let interval = setInterval(async () => {
try {
const newTriggers = await GcpTriggerService.listGcpTriggers({
workspace: $workspaceStore!
})
for (let i = 0; i < triggers.length; i++) {
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
if (newTrigger) {
triggers[i] = {
...triggers[i],
error: newTrigger.error,
last_server_ping: newTrigger.last_server_ping,
mode: newTrigger.mode,
server_id: newTrigger.server_id
}
}
}
} catch (err) {
console.error(err)
}
}, 5000)
onDestroy(() => {
clearInterval(interval)
})
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
let committed = false
try {
const ok = await withForkConflictRetry(
(force) =>
GcpTriggerService.setGcpTriggerMode({
path,
workspace: $workspaceStore!,
requestBody: { mode, force }
}),
'GCP Pub/Sub trigger'
)
committed = ok
if (ok) loadTriggers()
} catch (err) {
sendUserToast(
`Cannot ${mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend'} GCP Pub/Sub trigger: ${err.body}`,
true
)
loadTriggers()
}
return committed
}
run(() => {
if ($workspaceStore && $userStore) {
loadTriggers()
}
})
let gcpTriggerEditor: GcpTriggerEditor | undefined = $state()
let hashHandled = false
$effect(() => {
if (!hashHandled && triggers.length > 0 && gcpTriggerEditor) {
let hash = $page.url.hash
if (hash.length > 1) {
let path = hash.slice(1)
let trigger = triggers.find((t) => t.path === path)
if (trigger) {
hashHandled = true
gcpTriggerEditor?.openEdit(path, trigger.is_flow)
}
}
}
})
let filteredItems: (TriggerD & { marked?: any })[] | undefined = $state([])
let items: typeof filteredItems | undefined = $state([])
let preFilteredItems: typeof filteredItems | undefined = $state([])
let filter = $state('')
let ownerFilter: string | undefined = $state(undefined)
let nbDisplayed = $state(15)
let deleteSubscription = $state(false)
let deleteSubscriptionCallback: (() => Promise<void>) | undefined = $state(undefined)
let deleteGcpTriggerCallback: (() => Promise<void>) | undefined = $state(undefined)
let subscriptionToDelete: string = $state('')
let currentTopic: string = $state('')
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
let selectedFilterKind = $state(
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
)
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
run(() => {
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
})
run(() => {
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
})
function filterItemsPathsBaseOnUserFilters(
item: TriggerD,
selectedFilterKind: 'trigger' | 'script_flow',
filterUserFolders: boolean
) {
if ($workspaceStore == 'admins') return true
if (filterUserFolders) {
if (selectedFilterKind === 'trigger') {
return (
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
)
} else {
return (
!item.script_path.startsWith('u/') ||
item.script_path.startsWith('u/' + $userStore?.username + '/')
)
}
} else {
return true
}
}
run(() => {
preFilteredItems =
ownerFilter != undefined
? selectedFilterKind === 'trigger'
? triggers?.filter(
(x) =>
x.path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter(
(x) =>
x.script_path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter((x) =>
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
})
run(() => {
if ($workspaceStore) {
ownerFilter = undefined
}
})
let owners = $derived(
selectedFilterKind === 'trigger'
? Array.from(
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
: Array.from(
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
)
run(() => {
items = filter !== '' ? filteredItems : preFilteredItems
})
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
setQuery(
TRIGGER_PATH_KIND_FILTER_SETTING,
selectedFilterKind,
window.location.hash || undefined
).then(() => {
setQuery(
FILTER_USER_FOLDER_SETTING_NAME,
String(filterUserFolders),
window.location.hash || undefined
)
})
}
function loadQueryFilters() {
let url = new URL(window.location.href)
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
if (queryFilterKind) {
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
}
if (queryFilterUserFolders) {
filterUserFolders = queryFilterUserFolders == 'true'
}
}
onMount(() => {
loadQueryFilters()
})
run(() => {
updateQueryFilters(selectedFilterKind, filterUserFolders)
})
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
</script>
<ConfirmationModal
open={Boolean(deleteGcpTriggerCallback)}
title="Delete GCP Pub/Sub trigger"
confirmationText="Remove"
loading={isDeleting}
on:canceled={() => {
deleteGcpTriggerCallback = undefined
}}
on:confirmed={async () => {
if (deleteGcpTriggerCallback) {
isDeleting = true
if (deleteSubscription && deleteSubscriptionCallback) {
try {
await deleteSubscriptionCallback()
} catch (error) {
isDeleting = false
sendUserToast(error.body || error.message, true)
return
}
}
await deleteGcpTriggerCallback()
}
deleteGcpTriggerCallback = undefined
}}
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to remove this trigger?</span>
<Toggle
options={{
left: `Delete subscription "${subscriptionToDelete}" subscribed to topic "${currentTopic}"?`
}}
bind:checked={deleteSubscription}
/>
</div>
</ConfirmationModal>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<GcpTriggerEditor onUpdate={loadTriggers} bind:this={gcpTriggerEditor} />
<SearchItems
{filter}
items={preFilteredItems}
bind:filteredItems
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
/>
<CenteredPage>
<PageHeader title="GCP Pub/Sub triggers" tooltip="GCP Pub/Sub trigger">
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => gcpTriggerEditor?.openNew(false)}
>
New&nbsp;GCP Pub/Sub trigger
</Button>
</PageHeader>
{#if isCloudHosted()}
<Alert title="Not compatible with multi-tenant cloud" type="warning">
GCP Pub/Sub triggers are disabled in the multi-tenant cloud.
</Alert>
{/if}
<div class="w-full h-full flex flex-col">
<div class="w-full pb-4 pt-6">
<input type="text" placeholder="Search triggers" bind:value={filter} class="search-item" />
<div class="flex flex-row items-center gap-2 mt-6">
<div class="text-sm shrink-0"> Filter by path of </div>
<ToggleButtonGroup bind:selected={selectedFilterKind}>
{#snippet children({ item })}
<ToggleButton
value="trigger"
label="GCP Pub/Sub trigger"
icon={GoogleCloudIcon}
{item}
/>
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
{/snippet}
</ToggleButtonGroup>
</div>
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
<div class="flex flex-row items-center justify-end gap-4">
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
<Toggle
size="xs"
bind:checked={filterUserFolders}
options={{ right: `Only u/${$userStore.username} and f/*` }}
/>
{/if}
</div>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !triggers?.length}
<EmptyState
icon={GoogleCloudIcon}
title="No GCP Pub/Sub triggers yet"
description="Windmill can subscribe to a GCP Pub/Sub topic and trigger scripts or flows on each message."
action={{
label: 'Add a GCP Pub/Sub trigger',
icon: Plus,
onClick: () => gcpTriggerEditor?.openNew(false),
aiId: 'gcp-triggers-empty-add',
aiDescription: 'Add GCP Pub/Sub trigger'
}}
/>
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as { gcp_resource_path, project_id, topic_id, workspace_id, delivery_type, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, subscription_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
{@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_gcp', path) ?? is_draft}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
{@const ping = new Date()}
{@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000}
{@const effectiveMode = draft_only ? 'disabled' : mode}
{@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'}
<div
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-5 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
onclick={() => gcpTriggerEditor?.openEdit(path, is_flow)}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate">
{path} - {topic_id}{hasDraft ? '*' : ''}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
runnable: {script_path}
</div>
</a>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
{#if labels?.length}
{#each labels as label}
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
{/each}
{/if}
</div>
{#if delivery_type !== 'push'}
<div class="w-10">
{#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle
class="text-red-600 animate-ping absolute inline-flex fill-current"
size={12}
/>
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div>
{#if enabled}
{#if !server_id}
GCP Pub/Sub trigger is starting...
{:else}
Could not connect to GCP Pub/Sub{error ? ': ' + error : ''}
{/if}
{:else}
Disabled because of an error: {error}
{/if}
</div>
{/snippet}
</Popover>
{:else if enabled}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle
class="text-green-600 relative inline-flex fill-current"
size={12}
/>
</span>
{#snippet text()}
<div>
Connected to GCP Pub/Sub{!server_id ? ' (shutting down...)' : ''}</div
>
{/snippet}
</Popover>
{/if}
</div>
{/if}
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
<DraftBadge {draft_only} is_draft={hasDraft} />
{#if delivery_type !== 'push'}
<TriggerModeToggle
disabled={draft_only}
title={draft_only
? 'Draft only: deploy the trigger to enable it'
: hasDraft
? 'Enables/disables the deployed trigger; the draft is not affected'
: undefined}
onToggleMode={(newMode) => onToggleMode(path, newMode)}
triggerMode={effectiveMode}
includeModalConfig={{
triggerPath: path,
triggerKind: 'gcp',
runnableConfig: {
path: script_path,
kind: is_flow ? 'flow' : 'script',
retry,
errorHandlerPath: error_handler_path,
errorHandlerArgs: error_handler_args
}
}}
{canWrite}
hideToggleLabels
hideDropdown
/>
{/if}
</div>
<div class="flex gap-2 items-center justify-end">
{#if delivery_type === 'push'}
<Button
on:click={() =>
copyToClipboard(getHttpRoute('gcp/w', path, true, workspace_id))}
variant="subtle"
size="xs"
startIcon={{ icon: ClipboardCopy }}
>
Copy URL
</Button>
{/if}
<Button
on:click={() => gcpTriggerEditor?.openEdit(path, is_flow)}
size="xs"
startIcon={canWrite
? { icon: Pen }
: {
icon: Eye
}}
variant="subtle"
>
{canWrite ? 'Edit' : 'View'}
</Button>
<Dropdown
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
goto(href)
}
},
...(canWrite && !draft_only && mode !== 'suspended'
? [
{
displayName: 'Suspend job execution',
icon: Pause,
action: () => {
onToggleMode(path, 'suspended')
}
}
]
: []),
{
displayName: canWrite ? 'Edit' : 'View',
icon: canWrite ? Pen : Eye,
action: () => {
gcpTriggerEditor?.openEdit(path, is_flow)
}
},
...(isDeployable('trigger', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer?.openDrawer(path, 'trigger', {
triggers: {
kind: 'gcp'
}
})
}
}
]
: []),
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`
},
{
displayName: 'Permissions',
icon: Shield,
action: () => {
shareModal?.openDrawer(path, 'gcp_trigger')
}
},
{
displayName: 'Delete',
type: 'delete',
icon: Trash,
disabled: !canWrite,
action: async () => {
isDeleting = false
deleteSubscription = false
subscriptionToDelete = subscription_id
currentTopic = topic_id
deleteSubscriptionCallback = async () => {
const requestBody = { subscription_id, project_id }
const message = gcp_resource_path
? await GcpTriggerService.deleteGcpSubscription({
workspace: $workspaceStore ?? '',
path: gcp_resource_path,
requestBody
})
: await GcpTriggerService.deleteGcpSubscriptionWithDefaultCredentials({
workspace: $workspaceStore ?? '',
requestBody
})
sendUserToast(message)
}
deleteGcpTriggerCallback = async () => {
const message = await GcpTriggerService.deleteGcpTrigger({
workspace: $workspaceStore ?? '',
path
})
sendUserToast(message)
loadTriggers()
}
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
<div
class="flex flex-wrap text-[0.7em] text-primary gap-1 items-center justify-end truncate pr-2"
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
class="truncate">{edited_by ? 'the ' : ''}{displayDate(edited_at)}</div
></div
></div
>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
<span class="text-xs"
>{nbDisplayed} items out of {items.length}
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
>
{/if}
</CenteredPage>
<ShareModal
bind:this={shareModal}
on:change={() => {
loadTriggers()
}}
/>
<TriggerList triggerKind="gcp" />
@@ -1,595 +1,5 @@
<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import { run } from 'svelte/legacy'
import {
KafkaTriggerService,
WorkspaceService,
type KafkaTrigger,
type TriggerMode,
type WorkspaceDeployUISettings
} from '$lib/gen'
import {
canWrite,
displayDate,
getLocalSetting,
sendUserToast,
storeLocalSetting,
removeTriggerKindIfUnused
} from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { base } from '$app/paths'
import { page } from '$app/stores'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
import Dropdown from '$lib/components/DropdownV2.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import {
userStore,
workspaceStore,
userWorkspaces,
enterpriseLicense,
usedTriggerKinds
} from '$lib/stores'
import { Code, Eye, Pen, Plus, Shield, Trash, Circle, FileUp, Pause } from 'lucide-svelte'
import { goto } from '$lib/navigation'
import SearchItems from '$lib/components/SearchItems.svelte'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import ListFilters from '$lib/components/home/ListFilters.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { setQuery } from '$lib/navigation'
import { onDestroy, onMount } from 'svelte'
import Popover from '$lib/components/Popover.svelte'
import { isCloudHosted } from '$lib/cloud'
import KafkaIcon from '$lib/components/icons/KafkaIcon.svelte'
import KafkaTriggerEditor from '$lib/components/triggers/kafka/KafkaTriggerEditor.svelte'
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
type TriggerW = KafkaTrigger & { canWrite: boolean }
let triggers: TriggerW[] = $state([])
let shareModal: ShareModal | undefined = $state()
let loading = $state(true)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
async function loadTriggers(): Promise<void> {
triggers = (
await KafkaTriggerService.listKafkaTriggers({
workspace: $workspaceStore!,
includeDraftOnly: true
})
).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'kafka', $usedTriggerKinds)
loading = false
}
let interval = setInterval(async () => {
try {
const newTriggers = await KafkaTriggerService.listKafkaTriggers({
workspace: $workspaceStore!
})
for (let i = 0; i < triggers.length; i++) {
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
if (newTrigger) {
triggers[i] = {
...triggers[i],
error: newTrigger.error,
last_server_ping: newTrigger.last_server_ping,
mode: newTrigger.mode,
server_id: newTrigger.server_id
}
}
}
} catch (err) {
console.error(err)
}
}, 5000)
onDestroy(() => {
clearInterval(interval)
})
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
let committed = false
try {
const ok = await withForkConflictRetry(
(force) =>
KafkaTriggerService.setKafkaTriggerMode({
path,
workspace: $workspaceStore!,
requestBody: { mode, force }
}),
'Kafka trigger'
)
committed = ok
if (ok) loadTriggers()
} catch (err) {
sendUserToast(
`Cannot ` +
(mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend') +
` Kafka trigger: ${err.body}`,
true
)
loadTriggers()
}
return committed
}
run(() => {
if ($workspaceStore && $userStore) {
loadTriggers()
}
})
let kafkaTriggerEditor: KafkaTriggerEditor | undefined = $state()
let hashHandled = false
$effect(() => {
if (!hashHandled && triggers.length > 0 && kafkaTriggerEditor) {
let hash = $page.url.hash
if (hash.length > 1) {
let path = hash.slice(1)
let trigger = triggers.find((t) => t.path === path)
if (trigger) {
hashHandled = true
kafkaTriggerEditor?.openEdit(path, trigger.is_flow)
}
}
}
})
let filteredItems: (TriggerW & { marked?: any })[] | undefined = $state([])
let items: typeof filteredItems | undefined = $state([])
let preFilteredItems: typeof filteredItems | undefined = $state([])
let filter = $state('')
let ownerFilter: string | undefined = $state(undefined)
let nbDisplayed = $state(15)
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
let selectedFilterKind = $state(
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
)
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
run(() => {
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
})
run(() => {
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
})
function filterItemsPathsBaseOnUserFilters(
item: TriggerW,
selectedFilterKind: 'trigger' | 'script_flow',
filterUserFolders: boolean
) {
if ($workspaceStore == 'admins') return true
if (filterUserFolders) {
if (selectedFilterKind === 'trigger') {
return (
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
)
} else {
return (
!item.script_path.startsWith('u/') ||
item.script_path.startsWith('u/' + $userStore?.username + '/')
)
}
} else {
return true
}
}
run(() => {
preFilteredItems =
ownerFilter != undefined
? selectedFilterKind === 'trigger'
? triggers?.filter(
(x) =>
x.path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter(
(x) =>
x.script_path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter((x) =>
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
})
run(() => {
if ($workspaceStore) {
ownerFilter = undefined
}
})
let owners = $derived(
selectedFilterKind === 'trigger'
? Array.from(
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
: Array.from(
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
)
run(() => {
items = filter !== '' ? filteredItems : preFilteredItems
})
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
setQuery(
TRIGGER_PATH_KIND_FILTER_SETTING,
selectedFilterKind,
window.location.hash || undefined
).then(() => {
setQuery(
FILTER_USER_FOLDER_SETTING_NAME,
String(filterUserFolders),
window.location.hash || undefined
)
})
}
function loadQueryFilters() {
let url = new URL(window.location.href)
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
if (queryFilterKind) {
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
}
if (queryFilterUserFolders) {
filterUserFolders = queryFilterUserFolders == 'true'
}
}
onMount(() => {
loadQueryFilters()
})
run(() => {
updateQueryFilters(selectedFilterKind, filterUserFolders)
})
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
</script>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<KafkaTriggerEditor onUpdate={loadTriggers} bind:this={kafkaTriggerEditor} />
<SearchItems
{filter}
items={preFilteredItems}
bind:filteredItems
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
/>
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{:else}
<CenteredPage>
<PageHeader
title="Kafka triggers"
tooltip="Windmill can consume kafka events and trigger scripts or flows based on them."
>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => kafkaTriggerEditor?.openNew(false)}
>
New&nbsp;Kafka trigger
</Button>
</PageHeader>
{#if isCloudHosted()}
<Alert title="Not compatible with multi-tenant cloud" type="warning">
Kafka triggers are disabled in the multi-tenant cloud.
</Alert>
<div class="py-4"></div>
{/if}
<div class="w-full h-full flex flex-col">
<div class="w-full pb-4 pt-6">
<input
type="text"
placeholder="Search Kafka triggers"
bind:value={filter}
class="search-item"
/>
<div class="flex flex-row items-center gap-2 mt-6">
<div class="text-sm shrink-0"> Filter by path of </div>
<ToggleButtonGroup bind:selected={selectedFilterKind}>
{#snippet children({ item })}
<ToggleButton value="trigger" label="Kafka trigger" icon={KafkaIcon} {item} />
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
{/snippet}
</ToggleButtonGroup>
</div>
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
<div class="flex flex-row items-center justify-end gap-4">
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
<Toggle
size="xs"
bind:checked={filterUserFolders}
options={{ right: `Only u/${$userStore.username} and f/*` }}
/>
{/if}
</div>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !triggers?.length}
<EmptyState
icon={KafkaIcon}
title="No Kafka triggers yet"
description="Windmill can consume Kafka events and trigger scripts or flows based on them."
action={{
label: 'Add a Kafka trigger',
icon: Plus,
onClick: () => kafkaTriggerEditor?.openNew(false),
aiId: 'kafka-triggers-empty-add',
aiDescription: 'Add Kafka trigger'
}}
/>
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, kafka_resource_path, topics, extra_perms, canWrite, marked, server_id, error, last_server_ping, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
{@const hasDraft =
getLocalDraftHint($workspaceStore, 'trigger_kafka', path) ?? is_draft}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
{@const ping = last_server_ping ? new Date(last_server_ping) : undefined}
{@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000}
{@const effectiveMode = draft_only ? 'disabled' : mode}
{@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'}
<div
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-5 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
onclick={() => kafkaTriggerEditor?.openEdit(path, is_flow)}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div
class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate"
>
{#if marked}
<span class="text-xs">
{@html marked}
</span>
{:else}
{kafka_resource_path} - {topics.join(', ')}
{/if}{hasDraft ? '*' : ''}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
{path}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
runnable: {script_path}
</div>
</a>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
{#if labels?.length}
{#each labels as label}
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
{/each}
{/if}
</div>
<div class="w-10">
{#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle
class="text-red-600 animate-ping absolute inline-flex fill-current"
size={12}
/>
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div>
{#if enabled}
{#if !server_id}
Consumer is starting...
{:else}
Consumer is not connected{error ? ': ' + error : ''}
{/if}
{:else}
Consumer was disabled because of an error: {error}
{/if}
</div>
{/snippet}
</Popover>
{:else if enabled}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle
class="text-green-600 relative inline-flex fill-current"
size={12}
/>
</span>
{#snippet text()}
<div> Consumer is connected </div>
{/snippet}
</Popover>
{/if}
</div>
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
<DraftBadge {draft_only} is_draft={hasDraft} />
<TriggerModeToggle
disabled={draft_only}
title={draft_only
? 'Draft only: deploy the trigger to enable it'
: hasDraft
? 'Enables/disables the deployed trigger; the draft is not affected'
: undefined}
onToggleMode={(newMode) => onToggleMode(path, newMode)}
triggerMode={effectiveMode}
includeModalConfig={{
triggerPath: path,
triggerKind: 'kafka',
runnableConfig: {
path: script_path,
kind: is_flow ? 'flow' : 'script',
retry,
errorHandlerPath: error_handler_path,
errorHandlerArgs: error_handler_args
}
}}
{canWrite}
hideToggleLabels
hideDropdown
/>
</div>
<div class="flex gap-2 items-center justify-end">
<Button
on:click={() => kafkaTriggerEditor?.openEdit(path, is_flow)}
unifiedSize="md"
startIcon={canWrite
? { icon: Pen }
: {
icon: Eye
}}
variant="subtle"
>
{canWrite ? 'Edit' : 'View'}
</Button>
<Dropdown
size="md"
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
goto(href)
}
},
...(canWrite && !draft_only && mode !== 'suspended'
? [
{
displayName: 'Suspend job execution',
icon: Pause,
action: () => {
onToggleMode(path, 'suspended')
}
}
]
: []),
{
displayName: 'Delete',
type: 'delete',
icon: Trash,
disabled: !canWrite,
action: async () => {
await KafkaTriggerService.deleteKafkaTrigger({
workspace: $workspaceStore ?? '',
path
})
loadTriggers()
}
},
{
displayName: canWrite ? 'Edit' : 'View',
icon: canWrite ? Pen : Eye,
action: () => {
kafkaTriggerEditor?.openEdit(path, is_flow)
}
},
...(isDeployable('trigger', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer?.openDrawer(path, 'trigger', {
triggers: {
kind: 'kafka'
}
})
}
}
]
: []),
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`
},
{
displayName: 'Permissions',
icon: Shield,
action: () => {
shareModal?.openDrawer(path, 'kafka_trigger')
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
<div
class="flex flex-wrap text-2xs font-normal text-secondary gap-1 items-center justify-end truncate pr-2"
>{#if edited_by}<div class="truncate">Edited by {edited_by}</div>{/if}<div
class="truncate">{edited_by ? 'the ' : ''}{displayDate(edited_at)}</div
></div
></div
>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
<span class="text-xs"
>{nbDisplayed} items out of {items.length}
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
>
{/if}
</CenteredPage>
{/if}
<ShareModal
bind:this={shareModal}
on:change={() => {
loadTriggers()
}}
/>
<TriggerList triggerKind="kafka" />
@@ -1,570 +1,5 @@
<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import { run } from 'svelte/legacy'
import {
MqttTriggerService,
WorkspaceService,
type MqttTrigger,
type TriggerMode,
type WorkspaceDeployUISettings
} from '$lib/gen'
import {
canWrite,
capitalize,
displayDate,
getLocalSetting,
sendUserToast,
storeLocalSetting,
removeTriggerKindIfUnused
} from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { base } from '$app/paths'
import { page } from '$app/stores'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
import Dropdown from '$lib/components/DropdownV2.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { enterpriseLicense, usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { Code, Eye, Pen, Plus, Shield, Trash, Circle, FileUp, Pause } from 'lucide-svelte'
import { goto } from '$lib/navigation'
import SearchItems from '$lib/components/SearchItems.svelte'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import ListFilters from '$lib/components/home/ListFilters.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { setQuery } from '$lib/navigation'
import { onDestroy, onMount } from 'svelte'
import Popover from '$lib/components/Popover.svelte'
import { isCloudHosted } from '$lib/cloud'
import MqttTriggerEditor from '$lib/components/triggers/mqtt/MqttTriggerEditor.svelte'
import MqttIcon from '$lib/components/icons/MqttIcon.svelte'
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
type TriggerM = MqttTrigger & { canWrite: boolean }
let triggers: TriggerM[] = $state([])
let shareModal: ShareModal | undefined = $state()
let loading = $state(true)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
async function loadTriggers(): Promise<void> {
triggers = (
await MqttTriggerService.listMqttTriggers({
workspace: $workspaceStore!,
includeDraftOnly: true
})
).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'mqtt', $usedTriggerKinds)
loading = false
}
let interval = setInterval(async () => {
try {
const newTriggers = await MqttTriggerService.listMqttTriggers({
workspace: $workspaceStore!
})
for (let i = 0; i < triggers.length; i++) {
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
if (newTrigger) {
triggers[i] = {
...triggers[i],
error: newTrigger.error,
last_server_ping: newTrigger.last_server_ping,
mode: newTrigger.mode,
server_id: newTrigger.server_id
}
}
}
} catch (err) {
console.error(err)
}
}, 5000)
onDestroy(() => {
clearInterval(interval)
})
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
let committed = false
try {
const ok = await withForkConflictRetry(
(force) =>
MqttTriggerService.setMqttTriggerMode({
path,
workspace: $workspaceStore!,
requestBody: { mode, force }
}),
'MQTT trigger'
)
if (ok) {
sendUserToast(`${capitalize(mode)} MQTT trigger ${path}`)
loadTriggers()
}
committed = ok
} catch (err) {
sendUserToast(
`Cannot ${mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend'} mqtt trigger: ${err.body}`,
true
)
loadTriggers()
}
return committed
}
run(() => {
if ($workspaceStore && $userStore) {
loadTriggers()
}
})
let mqttTriggerEditor: MqttTriggerEditor | undefined = $state()
let hashHandled = false
$effect(() => {
if (!hashHandled && triggers.length > 0 && mqttTriggerEditor) {
let hash = $page.url.hash
if (hash.length > 1) {
let path = hash.slice(1)
let trigger = triggers.find((t) => t.path === path)
if (trigger) {
hashHandled = true
mqttTriggerEditor?.openEdit(path, trigger.is_flow)
}
}
}
})
let filteredItems: (TriggerM & { marked?: any })[] | undefined = $state([])
let items: typeof filteredItems | undefined = $state([])
let preFilteredItems: typeof filteredItems | undefined = $state([])
let filter = $state('')
let ownerFilter: string | undefined = $state(undefined)
let nbDisplayed = $state(15)
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
let selectedFilterKind = $state(
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
)
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
run(() => {
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
})
run(() => {
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
})
function filterItemsPathsBaseOnUserFilters(
item: TriggerM,
selectedFilterKind: 'trigger' | 'script_flow',
filterUserFolders: boolean
) {
if ($workspaceStore == 'admins') return true
if (filterUserFolders) {
if (selectedFilterKind === 'trigger') {
return (
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
)
} else {
return (
!item.script_path.startsWith('u/') ||
item.script_path.startsWith('u/' + $userStore?.username + '/')
)
}
} else {
return true
}
}
run(() => {
preFilteredItems =
ownerFilter != undefined
? selectedFilterKind === 'trigger'
? triggers?.filter(
(x) =>
x.path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter(
(x) =>
x.script_path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter((x) =>
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
})
run(() => {
if ($workspaceStore) {
ownerFilter = undefined
}
})
let owners = $derived(
selectedFilterKind === 'trigger'
? Array.from(
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
: Array.from(
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
)
run(() => {
items = filter !== '' ? filteredItems : preFilteredItems
})
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
setQuery(
TRIGGER_PATH_KIND_FILTER_SETTING,
selectedFilterKind,
window.location.hash || undefined
).then(() => {
setQuery(
FILTER_USER_FOLDER_SETTING_NAME,
String(filterUserFolders),
window.location.hash || undefined
)
})
}
function loadQueryFilters() {
let url = new URL(window.location.href)
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
if (queryFilterKind) {
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
}
if (queryFilterUserFolders) {
filterUserFolders = queryFilterUserFolders == 'true'
}
}
onMount(() => {
loadQueryFilters()
})
run(() => {
updateQueryFilters(selectedFilterKind, filterUserFolders)
})
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
</script>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<MqttTriggerEditor onUpdate={loadTriggers} bind:this={mqttTriggerEditor} />
<SearchItems
{filter}
items={preFilteredItems}
bind:filteredItems
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
/>
<CenteredPage>
<PageHeader
title="MQTT triggers"
tooltip="Windmill can connect to an MQTT broker, subscribe to specific topics, and trigger scripts or flows based on those topics."
>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => mqttTriggerEditor?.openNew(false)}
>
New&nbsp;MQTT trigger
</Button>
</PageHeader>
{#if isCloudHosted()}
<Alert title="Not compatible with multi-tenant cloud" type="warning">
MQTT triggers are disabled in the multi-tenant cloud.
</Alert>
<div class="py-4"></div>
{/if}
<div class="w-full h-full flex flex-col">
<div class="w-full pb-4 pt-6">
<input
type="text"
placeholder="Search MQTT triggers"
bind:value={filter}
class="search-item"
/>
<div class="flex flex-row items-center gap-2 mt-6">
<div class="text-sm shrink-0"> Filter by path of </div>
<ToggleButtonGroup bind:selected={selectedFilterKind}>
{#snippet children({ item })}
<ToggleButton value="trigger" label="MQTT trigger" icon={MqttIcon} {item} />
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
{/snippet}
</ToggleButtonGroup>
</div>
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
<div class="flex flex-row items-center justify-end gap-4">
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
<Toggle
size="xs"
bind:checked={filterUserFolders}
options={{ right: `Only u/${$userStore.username} and f/*` }}
/>
{/if}
</div>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !triggers?.length}
<EmptyState
icon={MqttIcon}
title="No MQTT triggers yet"
description="Windmill can connect to an MQTT broker, subscribe to specific topics, and trigger scripts or flows based on those topics."
action={{
label: 'Add an MQTT trigger',
icon: Plus,
onClick: () => mqttTriggerEditor?.openNew(false),
aiId: 'mqtt-triggers-empty-add',
aiDescription: 'Add MQTT trigger'
}}
/>
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, extra_perms, canWrite, error, last_server_ping, server_id, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
{@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_mqtt', path) ?? is_draft}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
{@const ping = last_server_ping ? new Date(last_server_ping) : undefined}
{@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000}
{@const effectiveMode = draft_only ? 'disabled' : mode}
{@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'}
<div
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-5 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
onclick={() => mqttTriggerEditor?.openEdit(path, is_flow)}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div class="text-emphasis font-semibold text-xs truncate text-left">
{path}{hasDraft ? '*' : ''}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
runnable: {script_path}
</div>
</a>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
{#if labels?.length}
{#each labels as label}
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
{/each}
{/if}
</div>
<div class="w-10">
{#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle
class="text-red-600 animate-ping absolute inline-flex fill-current"
size={12}
/>
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div>
{#if enabled}
{#if !server_id}
MQTT trigger is starting...
{:else}
MQTT trigger is not connected{error ? ': ' + error : ''}
{/if}
{:else}
MQTT trigger was disabled because of an error: {error}
{/if}
</div>
{/snippet}
</Popover>
{:else if enabled}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle class="text-green-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div>
MQTT trigger is connected{!server_id ? ' (shutting down...)' : ''}
</div>
{/snippet}
</Popover>
{/if}
</div>
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
<DraftBadge {draft_only} is_draft={hasDraft} />
<TriggerModeToggle
disabled={draft_only}
title={draft_only
? 'Draft only: deploy the trigger to enable it'
: hasDraft
? 'Enables/disables the deployed trigger; the draft is not affected'
: undefined}
onToggleMode={(newMode) => onToggleMode(path, newMode)}
triggerMode={effectiveMode}
includeModalConfig={{
triggerPath: path,
triggerKind: 'mqtt',
runnableConfig: {
path: script_path,
kind: is_flow ? 'flow' : 'script',
retry,
errorHandlerPath: error_handler_path,
errorHandlerArgs: error_handler_args
}
}}
{canWrite}
hideToggleLabels
hideDropdown
/>
</div>
<div class="flex gap-2 items-center justify-end">
<Button
on:click={() => mqttTriggerEditor?.openEdit(path, is_flow)}
unifiedSize="md"
startIcon={canWrite
? { icon: Pen }
: {
icon: Eye
}}
variant="subtle"
>
{canWrite ? 'Edit' : 'View'}
</Button>
<Dropdown
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
goto(href)
}
},
...(canWrite && !draft_only && mode !== 'suspended'
? [
{
displayName: 'Suspend job execution',
icon: Pause,
action: () => {
onToggleMode(path, 'suspended')
}
}
]
: []),
{
displayName: canWrite ? 'Edit' : 'View',
icon: canWrite ? Pen : Eye,
action: () => {
mqttTriggerEditor?.openEdit(path, is_flow)
}
},
...(isDeployable('trigger', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer?.openDrawer(path, 'trigger', {
triggers: {
kind: 'mqtt'
}
})
}
}
]
: []),
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`
},
{
displayName: 'Permissions',
icon: Shield,
action: () => {
shareModal?.openDrawer(path, 'mqtt_trigger')
}
},
{
displayName: 'Delete',
type: 'delete',
icon: Trash,
disabled: !canWrite,
action: async () => {
await MqttTriggerService.deleteMqttTrigger({
workspace: $workspaceStore ?? '',
path
})
loadTriggers()
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
<div
class="flex flex-wrap text-[0.7em] text-primary gap-1 items-center justify-end truncate pr-2"
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
class="truncate">{edited_by ? 'the ' : ''}{displayDate(edited_at)}</div
></div
></div
>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
<span class="text-xs"
>{nbDisplayed} items out of {items.length}
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
>
{/if}
</CenteredPage>
<ShareModal
bind:this={shareModal}
on:change={() => {
loadTriggers()
}}
/>
<TriggerList triggerKind="mqtt" />
@@ -1,592 +1,5 @@
<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import { run } from 'svelte/legacy'
import {
NatsTriggerService,
WorkspaceService,
type NatsTrigger,
type TriggerMode,
type WorkspaceDeployUISettings
} from '$lib/gen'
import {
canWrite,
displayDate,
getLocalSetting,
sendUserToast,
storeLocalSetting,
removeTriggerKindIfUnused
} from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { base } from '$app/paths'
import { page } from '$app/stores'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
import Dropdown from '$lib/components/DropdownV2.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import {
userStore,
workspaceStore,
userWorkspaces,
enterpriseLicense,
usedTriggerKinds
} from '$lib/stores'
import { Code, Eye, Pen, Plus, Shield, Trash, Circle, FileUp, Pause } from 'lucide-svelte'
import { goto } from '$lib/navigation'
import SearchItems from '$lib/components/SearchItems.svelte'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import ListFilters from '$lib/components/home/ListFilters.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { setQuery } from '$lib/navigation'
import { onDestroy, onMount } from 'svelte'
import Popover from '$lib/components/Popover.svelte'
import { isCloudHosted } from '$lib/cloud'
import NatsIcon from '$lib/components/icons/NatsIcon.svelte'
import NatsTriggerEditor from '$lib/components/triggers/nats/NatsTriggerEditor.svelte'
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
type TriggerW = NatsTrigger & { canWrite: boolean }
let triggers: TriggerW[] = $state([])
let shareModal: ShareModal | undefined = $state()
let loading = $state(true)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
async function loadTriggers(): Promise<void> {
triggers = (
await NatsTriggerService.listNatsTriggers({
workspace: $workspaceStore!,
includeDraftOnly: true
})
).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'nats', $usedTriggerKinds)
loading = false
}
let interval = setInterval(async () => {
try {
const newTriggers = await NatsTriggerService.listNatsTriggers({
workspace: $workspaceStore!
})
for (let i = 0; i < triggers.length; i++) {
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
if (newTrigger) {
triggers[i] = {
...triggers[i],
error: newTrigger.error,
last_server_ping: newTrigger.last_server_ping,
mode: newTrigger.mode,
server_id: newTrigger.server_id
}
}
}
} catch (err) {
console.error(err)
}
}, 5000)
onDestroy(() => {
clearInterval(interval)
})
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
let committed = false
try {
const ok = await withForkConflictRetry(
(force) =>
NatsTriggerService.setNatsTriggerMode({
path,
workspace: $workspaceStore!,
requestBody: { mode, force }
}),
'NATS trigger'
)
committed = ok
if (ok) loadTriggers()
} catch (err) {
sendUserToast(
`Cannot ` +
(mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend') +
` NATS trigger: ${err.body}`,
true
)
loadTriggers()
}
return committed
}
run(() => {
if ($workspaceStore && $userStore) {
loadTriggers()
}
})
let natsTriggerEditor: NatsTriggerEditor | undefined = $state()
let hashHandled = false
$effect(() => {
if (!hashHandled && triggers.length > 0 && natsTriggerEditor) {
let hash = $page.url.hash
if (hash.length > 1) {
let path = hash.slice(1)
let trigger = triggers.find((t) => t.path === path)
if (trigger) {
hashHandled = true
natsTriggerEditor?.openEdit(path, trigger.is_flow)
}
}
}
})
let filteredItems: (TriggerW & { marked?: any })[] | undefined = $state([])
let items: typeof filteredItems | undefined = $state([])
let preFilteredItems: typeof filteredItems | undefined = $state([])
let filter = $state('')
let ownerFilter: string | undefined = $state(undefined)
let nbDisplayed = $state(15)
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
let selectedFilterKind = $state(
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
)
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
run(() => {
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
})
run(() => {
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
})
function filterItemsPathsBaseOnUserFilters(
item: TriggerW,
selectedFilterKind: 'trigger' | 'script_flow',
filterUserFolders: boolean
) {
if ($workspaceStore == 'admins') return true
if (filterUserFolders) {
if (selectedFilterKind === 'trigger') {
return (
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
)
} else {
return (
!item.script_path.startsWith('u/') ||
item.script_path.startsWith('u/' + $userStore?.username + '/')
)
}
} else {
return true
}
}
run(() => {
preFilteredItems =
ownerFilter != undefined
? selectedFilterKind === 'trigger'
? triggers?.filter(
(x) =>
x.path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter(
(x) =>
x.script_path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter((x) =>
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
})
run(() => {
if ($workspaceStore) {
ownerFilter = undefined
}
})
let owners = $derived(
selectedFilterKind === 'trigger'
? Array.from(
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
: Array.from(
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
)
run(() => {
items = filter !== '' ? filteredItems : preFilteredItems
})
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
setQuery(
TRIGGER_PATH_KIND_FILTER_SETTING,
selectedFilterKind,
window.location.hash || undefined
).then(() => {
setQuery(
FILTER_USER_FOLDER_SETTING_NAME,
String(filterUserFolders),
window.location.hash || undefined
)
})
}
function loadQueryFilters() {
let url = new URL(window.location.href)
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
if (queryFilterKind) {
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
}
if (queryFilterUserFolders) {
filterUserFolders = queryFilterUserFolders == 'true'
}
}
onMount(() => {
loadQueryFilters()
})
run(() => {
updateQueryFilters(selectedFilterKind, filterUserFolders)
})
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
</script>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<NatsTriggerEditor onUpdate={loadTriggers} bind:this={natsTriggerEditor} />
<SearchItems
{filter}
items={preFilteredItems}
bind:filteredItems
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
/>
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{:else}
<CenteredPage>
<PageHeader
title="NATS triggers"
tooltip="Windmill can consume NATS events and trigger scripts or flows based on them."
>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => natsTriggerEditor?.openNew(false)}
>
New&nbsp;NATS trigger
</Button>
</PageHeader>
{#if isCloudHosted()}
<Alert title="Not compatible with multi-tenant cloud" type="warning">
NATS triggers are disabled in the multi-tenant cloud.
</Alert>
<div class="py-4"></div>
{/if}
<div class="w-full h-full flex flex-col">
<div class="w-full pb-4 pt-6">
<input
type="text"
placeholder="Search NATS triggers"
bind:value={filter}
class="search-item"
/>
<div class="flex flex-row items-center gap-2 mt-2">
<div class="text-xs font-semibold text-emphasis shrink-0"> Filter by path of </div>
<ToggleButtonGroup bind:selected={selectedFilterKind}>
{#snippet children({ item })}
<ToggleButton value="trigger" label="NATS trigger" icon={NatsIcon} {item} />
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
{/snippet}
</ToggleButtonGroup>
</div>
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
<div class="flex flex-row items-center justify-end gap-4">
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
<Toggle
size="xs"
bind:checked={filterUserFolders}
options={{ right: `Only u/${$userStore.username} and f/*` }}
/>
{/if}
</div>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !triggers?.length}
<EmptyState
icon={NatsIcon}
title="No NATS triggers yet"
description="Windmill can consume NATS events and trigger scripts or flows based on them."
action={{
label: 'Add a NATS trigger',
icon: Plus,
onClick: () => natsTriggerEditor?.openNew(false),
aiId: 'nats-triggers-empty-add',
aiDescription: 'Add NATS trigger'
}}
/>
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, nats_resource_path, subjects, extra_perms, canWrite, marked, server_id, error, last_server_ping, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
{@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_nats', path) ?? is_draft}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
{@const ping = last_server_ping ? new Date(last_server_ping) : undefined}
{@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000}
{@const effectiveMode = draft_only ? 'disabled' : mode}
{@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'}
<div
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-5 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
onclick={() => natsTriggerEditor?.openEdit(path, is_flow)}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div
class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate"
>
{#if marked}
<span class="text-xs">
{@html marked}
</span>
{:else}
{nats_resource_path} - {subjects.join(', ')}
{/if}{hasDraft ? '*' : ''}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
{path}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
runnable: {script_path}
</div>
</a>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
{#if labels?.length}
{#each labels as label}
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
{/each}
{/if}
</div>
<div class="w-10">
{#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle
class="text-red-600 animate-ping absolute inline-flex fill-current"
size={12}
/>
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div>
{#if enabled}
{#if !server_id}
Consumer is starting...
{:else}
Consumer is not connected{error ? ': ' + error : ''}
{/if}
{:else}
Consumer was disabled because of an error: {error}
{/if}
</div>
{/snippet}
</Popover>
{:else if enabled}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle
class="text-green-600 relative inline-flex fill-current"
size={12}
/>
</span>
{#snippet text()}
<div> Consumer is connected </div>
{/snippet}
</Popover>
{/if}
</div>
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
<DraftBadge {draft_only} is_draft={hasDraft} />
<TriggerModeToggle
disabled={draft_only}
title={draft_only
? 'Draft only: deploy the trigger to enable it'
: hasDraft
? 'Enables/disables the deployed trigger; the draft is not affected'
: undefined}
onToggleMode={(newMode) => onToggleMode(path, newMode)}
triggerMode={effectiveMode}
includeModalConfig={{
triggerPath: path,
triggerKind: 'nats',
runnableConfig: {
path: script_path,
kind: is_flow ? 'flow' : 'script',
retry,
errorHandlerPath: error_handler_path,
errorHandlerArgs: error_handler_args
}
}}
{canWrite}
hideToggleLabels
hideDropdown
/>
</div>
<div class="flex gap-2 items-center justify-end">
<Button
on:click={() => natsTriggerEditor?.openEdit(path, is_flow)}
unifiedSize="md"
startIcon={canWrite
? { icon: Pen }
: {
icon: Eye
}}
variant="subtle"
>
{canWrite ? 'Edit' : 'View'}
</Button>
<Dropdown
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
goto(href)
}
},
...(canWrite && !draft_only && mode !== 'suspended'
? [
{
displayName: 'Suspend job execution',
icon: Pause,
action: () => {
onToggleMode(path, 'suspended')
}
}
]
: []),
{
displayName: canWrite ? 'Edit' : 'View',
icon: canWrite ? Pen : Eye,
action: () => {
natsTriggerEditor?.openEdit(path, is_flow)
}
},
...(isDeployable('trigger', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer?.openDrawer(path, 'trigger', {
triggers: {
kind: 'nats'
}
})
}
}
]
: []),
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`
},
{
displayName: 'Permissions',
icon: Shield,
action: () => {
shareModal?.openDrawer(path, 'nats_trigger')
}
},
{
displayName: 'Delete',
type: 'delete',
icon: Trash,
disabled: !canWrite,
action: async () => {
await NatsTriggerService.deleteNatsTrigger({
workspace: $workspaceStore ?? '',
path
})
loadTriggers()
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
<div
class="flex flex-wrap text-2xs text-secondary gap-1 items-center justify-end truncate pr-2"
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
class="truncate">{edited_by ? 'the ' : ''}{displayDate(edited_at)}</div
></div
></div
>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
<span class="text-xs"
>{nbDisplayed} items out of {items.length}
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
>
{/if}
</CenteredPage>
{/if}
<ShareModal
bind:this={shareModal}
on:change={() => {
loadTriggers()
}}
/>
<TriggerList triggerKind="nats" />
@@ -1,674 +1,5 @@
<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import { run } from 'svelte/legacy'
import {
PostgresTriggerService,
WorkspaceService,
type PostgresTrigger,
type TriggerMode,
type WorkspaceDeployUISettings
} from '$lib/gen'
import {
canWrite,
displayDate,
getLocalSetting,
sendUserToast,
storeLocalSetting,
removeTriggerKindIfUnused,
capitalize
} from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { base } from '$app/paths'
import { page } from '$app/stores'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
import Dropdown from '$lib/components/DropdownV2.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { enterpriseLicense, usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import {
Code,
Eye,
Pen,
Plus,
Shield,
Trash,
Circle,
Database,
FileUp,
Pause
} from 'lucide-svelte'
import { goto } from '$lib/navigation'
import SearchItems from '$lib/components/SearchItems.svelte'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import ListFilters from '$lib/components/home/ListFilters.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { setQuery } from '$lib/navigation'
import { onDestroy, onMount } from 'svelte'
import Popover from '$lib/components/Popover.svelte'
import { isCloudHosted } from '$lib/cloud'
import PostgresTriggerEditor from '$lib/components/triggers/postgres/PostgresTriggerEditor.svelte'
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
type TriggerD = PostgresTrigger & { canWrite: boolean }
let triggers: TriggerD[] = $state([])
let shareModal: ShareModal | undefined = $state()
let loading = $state(true)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
async function loadTriggers(): Promise<void> {
triggers = (
await PostgresTriggerService.listPostgresTriggers({
workspace: $workspaceStore!,
includeDraftOnly: true
})
).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'postgres', $usedTriggerKinds)
loading = false
}
let interval = setInterval(async () => {
try {
const newTriggers = await PostgresTriggerService.listPostgresTriggers({
workspace: $workspaceStore!
})
for (let i = 0; i < triggers.length; i++) {
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
if (newTrigger) {
triggers[i] = {
...triggers[i],
error: newTrigger.error,
last_server_ping: newTrigger.last_server_ping,
mode: newTrigger.mode,
server_id: newTrigger.server_id
}
}
}
} catch (err) {
console.error(err)
}
}, 5000)
onDestroy(() => {
clearInterval(interval)
})
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
let committed = false
try {
const ok = await withForkConflictRetry(
(force) =>
PostgresTriggerService.setPostgresTriggerMode({
path,
workspace: $workspaceStore!,
requestBody: { mode, force }
}),
'postgres trigger'
)
if (ok) {
sendUserToast(`${capitalize(mode)} postgres trigger ${path}`)
loadTriggers()
}
committed = ok
} catch (err) {
sendUserToast(`Cannot change postgres trigger mode: ${err.body}`, true)
loadTriggers()
}
return committed
}
run(() => {
if ($workspaceStore && $userStore) {
loadTriggers()
}
})
let postgresTriggerEditor: PostgresTriggerEditor | undefined = $state()
let hashHandled = false
$effect(() => {
if (!hashHandled && triggers.length > 0 && postgresTriggerEditor) {
let hash = $page.url.hash
if (hash.length > 1) {
let path = hash.slice(1)
let trigger = triggers.find((t) => t.path === path)
if (trigger) {
hashHandled = true
postgresTriggerEditor?.openEdit(path, trigger.is_flow)
}
}
}
})
let filteredItems: (TriggerD & { marked?: any })[] | undefined = $state([])
let items: typeof filteredItems | undefined = $state([])
let preFilteredItems: typeof filteredItems | undefined = $state([])
let filter = $state('')
let ownerFilter: string | undefined = $state(undefined)
let nbDisplayed = $state(15)
let isDeleting: boolean = $state(false)
let deleteReplicationSlot: boolean = $state(false)
let deletePublication: boolean = $state(false)
let replicationSlotToDelete = $state('')
let publicationToDelete = $state('')
let deleteReplicationSlotCallback: (() => Promise<string>) | undefined = $state(undefined)
let deletePublicationCallback: (() => Promise<string>) | undefined = $state(undefined)
let deletePostgresTriggerCallback: (() => Promise<string>) | undefined = $state(undefined)
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
let selectedFilterKind = $state(
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
)
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
run(() => {
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
})
run(() => {
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
})
function filterItemsPathsBaseOnUserFilters(
item: TriggerD,
selectedFilterKind: 'trigger' | 'script_flow',
filterUserFolders: boolean
) {
if ($workspaceStore == 'admins') return true
if (filterUserFolders) {
if (selectedFilterKind === 'trigger') {
return (
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
)
} else {
return (
!item.script_path.startsWith('u/') ||
item.script_path.startsWith('u/' + $userStore?.username + '/')
)
}
} else {
return true
}
}
run(() => {
preFilteredItems =
ownerFilter != undefined
? selectedFilterKind === 'trigger'
? triggers?.filter(
(x) =>
x.path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter(
(x) =>
x.script_path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter((x) =>
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
})
run(() => {
if ($workspaceStore) {
ownerFilter = undefined
}
})
let owners = $derived(
selectedFilterKind === 'trigger'
? Array.from(
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
: Array.from(
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
)
run(() => {
items = filter !== '' ? filteredItems : preFilteredItems
})
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
setQuery(
TRIGGER_PATH_KIND_FILTER_SETTING,
selectedFilterKind,
window.location.hash || undefined
).then(() => {
setQuery(
FILTER_USER_FOLDER_SETTING_NAME,
String(filterUserFolders),
window.location.hash || undefined
)
})
}
function loadQueryFilters() {
let url = new URL(window.location.href)
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
if (queryFilterKind) {
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
}
if (queryFilterUserFolders) {
filterUserFolders = queryFilterUserFolders == 'true'
}
}
onMount(() => {
loadQueryFilters()
})
run(() => {
updateQueryFilters(selectedFilterKind, filterUserFolders)
})
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
</script>
<ConfirmationModal
open={Boolean(deletePostgresTriggerCallback)}
title="Delete Postgres trigger"
confirmationText="Remove"
loading={isDeleting}
on:canceled={() => {
isDeleting = false
deletePostgresTriggerCallback = undefined
}}
on:confirmed={async () => {
if (deletePostgresTriggerCallback) {
let msg: string
isDeleting = true
if (deleteReplicationSlot && deleteReplicationSlotCallback) {
try {
msg = await deleteReplicationSlotCallback()
sendUserToast(msg)
} catch (error) {
isDeleting = false
sendUserToast(error.body || error.message, true)
return
}
}
if (deletePublication && deletePublicationCallback) {
try {
msg = await deletePublicationCallback()
sendUserToast(msg)
} catch (error) {
isDeleting = false
sendUserToast(error.body || error.message, true)
return
}
}
msg = await deletePostgresTriggerCallback()
sendUserToast(msg)
}
deletePostgresTriggerCallback = undefined
}}
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to remove this trigger?</span>
<Toggle
options={{
left: `Delete the associated replication slot named: ${replicationSlotToDelete} ?`
}}
bind:checked={deleteReplicationSlot}
/>
<Toggle
options={{
left: `Delete the associated publication named: ${publicationToDelete} ?`
}}
bind:checked={deletePublication}
/>
</div>
</ConfirmationModal>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<PostgresTriggerEditor onUpdate={loadTriggers} bind:this={postgresTriggerEditor} />
<SearchItems
{filter}
items={preFilteredItems}
bind:filteredItems
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
/>
<CenteredPage>
<PageHeader
title="Postgres triggers"
tooltip="Windmill enables real-time responsiveness by listening to specific database transactions—such as inserts, updates, and deletes—and automatically triggering scripts or workflows in response."
>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => postgresTriggerEditor?.openNew(false)}
>
New&nbsp;Postgres trigger
</Button>
</PageHeader>
{#if isCloudHosted()}
<Alert title="Not compatible with multi-tenant cloud" type="warning">
Postgres triggers are disabled in the multi-tenant cloud.
</Alert>
<div class="py-4"></div>
{/if}
<div class="w-full h-full flex flex-col">
<div class="w-full pb-4 pt-6">
<TextInput
inputProps={{
placeholder: 'Search Postgres triggers',
class: 'search-item'
}}
bind:value={filter}
/>
<div class="flex flex-row items-center gap-2 mt-2">
<div class="text-xs font-semibold text-emphasis shrink-0"> Filter by path of </div>
<ToggleButtonGroup bind:selected={selectedFilterKind}>
{#snippet children({ item })}
<ToggleButton value="trigger" label="Postgres trigger" icon={Database} {item} />
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
{/snippet}
</ToggleButtonGroup>
</div>
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
<div class="flex flex-row items-center justify-end gap-4">
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
<Toggle
size="xs"
bind:checked={filterUserFolders}
options={{ right: `Only u/${$userStore.username} and f/*` }}
/>
{/if}
</div>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !triggers?.length}
<EmptyState
icon={Database}
title="No Postgres triggers yet"
description="Windmill can listen to database transactions — inserts, updates and deletes — and trigger scripts or flows in response."
action={{
label: 'Add a Postgres trigger',
icon: Plus,
onClick: () => postgresTriggerEditor?.openNew(false),
aiId: 'postgres-triggers-empty-add',
aiDescription: 'Add Postgres trigger'
}}
/>
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as { postgres_resource_path, publication_name, replication_slot_name, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
{@const hasDraft =
getLocalDraftHint($workspaceStore, 'trigger_postgres', path) ?? is_draft}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
{@const ping = new Date()}
{@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000}
{@const effectiveMode = draft_only ? 'disabled' : mode}
{@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'}
<div
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-5 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
onclick={() => postgresTriggerEditor?.openEdit(path, is_flow)}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate">
{path}{hasDraft ? '*' : ''}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
{postgres_resource_path}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
runnable: {script_path}
</div>
</a>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
{#if labels?.length}
{#each labels as label}
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
{/each}
{/if}
</div>
<div class="w-10">
{#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle
class="text-red-600 animate-ping absolute inline-flex fill-current"
size={12}
/>
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div>
{#if enabled}
{#if !server_id}
Postgres trigger is starting...
{:else}
Could not connect to database{error ? ': ' + error : ''}
{/if}
{:else}
Disabled because of an error: {error}
{/if}
</div>
{/snippet}
</Popover>
{:else if enabled}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle class="text-green-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div> Connected to database{!server_id ? ' (shutting down...)' : ''}</div>
{/snippet}
</Popover>
{/if}
</div>
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
<DraftBadge {draft_only} is_draft={hasDraft} />
<TriggerModeToggle
disabled={draft_only}
title={draft_only
? 'Draft only: deploy the trigger to enable it'
: hasDraft
? 'Enables/disables the deployed trigger; the draft is not affected'
: undefined}
onToggleMode={(newMode) => onToggleMode(path, newMode)}
triggerMode={effectiveMode}
includeModalConfig={{
triggerPath: path,
triggerKind: 'postgres',
runnableConfig: {
path: script_path,
kind: is_flow ? 'flow' : 'script',
retry,
errorHandlerPath: error_handler_path,
errorHandlerArgs: error_handler_args
}
}}
{canWrite}
hideToggleLabels
hideDropdown
/>
</div>
<div class="flex gap-2 items-center justify-end">
<Button
on:click={() => postgresTriggerEditor?.openEdit(path, is_flow)}
size="xs"
startIcon={canWrite
? { icon: Pen }
: {
icon: Eye
}}
variant="subtle"
>
{canWrite ? 'Edit' : 'View'}
</Button>
<Dropdown
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
goto(href)
}
},
...(canWrite && !draft_only && mode !== 'suspended'
? [
{
displayName: 'Suspend job execution',
icon: Pause,
action: () => {
onToggleMode(path, 'suspended')
}
}
]
: []),
{
displayName: 'Delete',
type: 'delete',
icon: Trash,
disabled: !canWrite,
action: async () => {
publicationToDelete = publication_name
replicationSlotToDelete = replication_slot_name
isDeleting = false
deletePublication = false
deleteReplicationSlot = false
deletePublicationCallback = async () => {
return await PostgresTriggerService.deletePostgresPublication({
workspace: $workspaceStore!,
path: postgres_resource_path,
publication: publication_name
})
}
deleteReplicationSlotCallback = async () => {
return await PostgresTriggerService.deletePostgresReplicationSlot({
workspace: $workspaceStore!,
path: postgres_resource_path,
requestBody: {
name: replication_slot_name
}
})
}
deletePostgresTriggerCallback = async () => {
const msg = await PostgresTriggerService.deletePostgresTrigger({
workspace: $workspaceStore ?? '',
path
})
loadTriggers()
return msg
}
}
},
{
displayName: canWrite ? 'Edit' : 'View',
icon: canWrite ? Pen : Eye,
action: () => {
postgresTriggerEditor?.openEdit(path, is_flow)
}
},
...(isDeployable('trigger', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer?.openDrawer(path, 'trigger', {
triggers: {
kind: 'postgres'
}
})
}
}
]
: []),
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`
},
{
displayName: 'Permissions',
icon: Shield,
action: () => {
shareModal?.openDrawer(path, 'postgres_trigger')
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
<div
class="flex flex-wrap text-[0.7em] text-primary gap-1 items-center justify-end truncate pr-2"
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
class="truncate">{edited_by ? 'the ' : ''}{displayDate(edited_at)}</div
></div
></div
>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
<span class="text-xs"
>{nbDisplayed} items out of {items.length}
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
>
{/if}
</CenteredPage>
<ShareModal
bind:this={shareModal}
on:change={() => {
loadTriggers()
}}
/>
<TriggerList triggerKind="postgres" />
File diff suppressed because it is too large Load Diff
@@ -1,592 +1,5 @@
<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import { run } from 'svelte/legacy'
import {
HttpTriggerService,
SettingService,
WorkspaceService,
type HttpTrigger,
type TriggerMode,
type WorkspaceDeployUISettings
} from '$lib/gen'
import {
canWrite,
copyToClipboard,
displayDate,
getLocalSetting,
storeLocalSetting,
removeTriggerKindIfUnused,
sendUserToast
} from '$lib/utils'
import { base } from '$app/paths'
import { page } from '$app/stores'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Button, EmptyState, Skeleton } from '$lib/components/common'
import Dropdown from '$lib/components/DropdownV2.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import {
userStore,
workspaceStore,
userWorkspaces,
enterpriseLicense,
usedTriggerKinds
} from '$lib/stores'
import {
Route,
Code,
Eye,
Pen,
Plus,
Shield,
Trash,
FileUp,
ClipboardCopy,
Pause
} from 'lucide-svelte'
import { goto } from '$lib/navigation'
import SearchItems from '$lib/components/SearchItems.svelte'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import ListFilters from '$lib/components/home/ListFilters.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { setQuery } from '$lib/navigation'
import { onMount } from 'svelte'
import RouteEditor from '$lib/components/triggers/http/RouteEditor.svelte'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
import { isCloudHosted } from '$lib/cloud'
import { getHttpRoute } from '$lib/components/triggers/http/utils'
import RoutesGenerator from '$lib/components/triggers/http/RoutesGenerator.svelte'
import OpenApiSpecGenerator from '$lib/components/triggers/http/OpenAPISpecGenerator.svelte'
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
type TriggerW = HttpTrigger & { canWrite: boolean }
let triggers: TriggerW[] = $state([])
let shareModal: ShareModal | undefined = $state()
let loading = $state(true)
let openAPISpecGenerator: OpenApiSpecGenerator | undefined = $state()
let routesGenerator: RoutesGenerator | undefined = $state()
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
let globalHttpWorkspacedRoute = $state(false)
async function loadGlobalHttpWorkspacedRouteSetting() {
try {
const setting = await SettingService.getGlobal({ key: 'http_route_workspaced_route' })
globalHttpWorkspacedRoute = (setting as boolean) ?? false
} catch {
globalHttpWorkspacedRoute = false
}
}
loadGlobalHttpWorkspacedRouteSetting()
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
async function loadTriggers(): Promise<void> {
triggers = (
await HttpTriggerService.listHttpTriggers({
workspace: $workspaceStore!,
includeDraftOnly: true
})
).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'routes', $usedTriggerKinds)
loading = false
}
run(() => {
if ($workspaceStore && $userStore) {
loadTriggers()
}
})
let routeEditor: RouteEditor | undefined = $state()
let hashHandled = false
$effect(() => {
if (!hashHandled && triggers.length > 0 && routeEditor) {
let hash = $page.url.hash
if (hash.length > 1) {
let path = hash.slice(1)
let trigger = triggers.find((t) => t.path === path)
if (trigger) {
hashHandled = true
routeEditor?.openEdit(path, trigger.is_flow)
}
}
}
})
let filteredItems: (TriggerW & { marked?: any })[] | undefined = $state([])
let items: typeof filteredItems | undefined = $state([])
let preFilteredItems: typeof filteredItems | undefined = $state([])
let filter = $state('')
let ownerFilter: string | undefined = $state(undefined)
let nbDisplayed = $state(15)
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
let selectedFilterKind = $state(
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
)
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
run(() => {
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
})
run(() => {
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
})
function filterItemsPathsBaseOnUserFilters(
item: TriggerW,
selectedFilterKind: 'trigger' | 'script_flow',
filterUserFolders: boolean
) {
if ($workspaceStore == 'admins') return true
if (filterUserFolders) {
if (selectedFilterKind === 'trigger') {
return (
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
)
} else {
return (
!item.script_path.startsWith('u/') ||
item.script_path.startsWith('u/' + $userStore?.username + '/')
)
}
} else {
return true
}
}
run(() => {
preFilteredItems =
ownerFilter != undefined
? selectedFilterKind === 'trigger'
? triggers?.filter(
(x) =>
x.path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter(
(x) =>
x.script_path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter((x) =>
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
})
run(() => {
if ($workspaceStore) {
ownerFilter = undefined
}
})
let owners = $derived(
selectedFilterKind === 'trigger'
? Array.from(
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
: Array.from(
new Set(
filteredItems
?.filter((x) => x.script_path)
.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? []
)
).sort()
)
run(() => {
items = filter !== '' ? filteredItems : preFilteredItems
})
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
setQuery(
TRIGGER_PATH_KIND_FILTER_SETTING,
selectedFilterKind,
window.location.hash || undefined
).then(() => {
setQuery(
FILTER_USER_FOLDER_SETTING_NAME,
String(filterUserFolders),
window.location.hash || undefined
)
})
}
function loadQueryFilters() {
let url = new URL(window.location.href)
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
if (queryFilterKind) {
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
}
if (queryFilterUserFolders) {
filterUserFolders = queryFilterUserFolders == 'true'
}
}
async function onToggleMode(path: string, mode: TriggerMode): Promise<void> {
try {
// HTTP routes are always workspace-prefixed at runtime, so fork
// and parent live at distinct URLs — no fork-conflict warning.
await HttpTriggerService.setHttpTriggerMode({
path,
workspace: $workspaceStore!,
requestBody: { mode }
})
} catch (err) {
sendUserToast(
`Cannot ` +
(mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend') +
` http trigger: ${err.body}`,
true
)
} finally {
loadTriggers()
}
}
onMount(() => {
loadQueryFilters()
})
run(() => {
updateQueryFilters(selectedFilterKind, filterUserFolders)
})
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
</script>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<RouteEditor onUpdate={loadTriggers} bind:this={routeEditor} />
<RoutesGenerator closeFn={loadTriggers} bind:this={routesGenerator} />
<OpenApiSpecGenerator bind:this={openAPISpecGenerator} />
<SearchItems
{filter}
items={preFilteredItems}
bind:filteredItems
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
/>
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{:else}
<CenteredPage>
<PageHeader
title="Custom HTTP routes"
tooltip="Every script and flow already has a canonical HTTP API endpoint/webhook attached to it, this is to create additional parametrizable ones."
documentationLink="https://www.windmill.dev/docs/core_concepts/http_routing"
>
<div class="flex flex-row gap-2">
<Button
unifiedSize="md"
variant="default"
startIcon={{ icon: Plus }}
on:click={() => {
routesGenerator?.openDrawer()
}}
>
From OpenAPI spec
</Button>
<Button
unifiedSize="md"
variant="default"
startIcon={{ icon: Plus }}
on:click={() => {
openAPISpecGenerator?.openDrawer()
}}
>
To OpenAPI spec
</Button>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => routeEditor?.openNew(false)}
>
New&nbsp;route
</Button>
</div>
</PageHeader>
<div class="w-full h-full flex flex-col">
<div class="w-full pb-4 pt-6">
<input type="text" placeholder="Search routes" bind:value={filter} class="search-item" />
<div class="flex flex-row items-center gap-2 mt-2">
<div class="text-xs font-semibold text-emphasis shrink-0"> Filter by path of </div>
<ToggleButtonGroup bind:selected={selectedFilterKind}>
{#snippet children({ item })}
<ToggleButton value="trigger" label="Route" icon={Route} {item} />
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
{/snippet}
</ToggleButtonGroup>
</div>
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
<div class="flex flex-row items-center justify-end gap-4">
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
<Toggle
size="xs"
bind:checked={filterUserFolders}
options={{ right: `Only u/${$userStore.username} and f/*` }}
/>
{/if}
</div>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !triggers?.length}
<EmptyState
icon={Route}
title="No custom HTTP routes yet"
description="Every script and flow already has a canonical HTTP endpoint attached to it. These are additional parametrizable ones."
action={{
label: 'Add a route',
icon: Plus,
onClick: () => routeEditor?.openNew(false),
aiId: 'routes-empty-add',
aiDescription: 'Add route'
}}
/>
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as { summary, workspace_id, workspaced_route, mode, path, edited_by, edited_at, script_path, route_path, is_flow, extra_perms, canWrite, marked, http_method, static_asset_config, retry, error_handler_path, error_handler_args, draft_only, is_draft } (path)}
{@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_http', path) ?? is_draft}
{@const effectiveMode = draft_only ? 'disabled' : mode}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
<div
class="bg-surface-tertiary hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-4 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
onclick={() => routeEditor?.openEdit(path, is_flow)}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div
class="text-emphasis font-semibold flex-wrap text-left text-xs mb-1 truncate"
>
{#if marked}
<span class="text-xs">
{@html marked}
</span>
{:else if summary}
{summary}
{:else}
{http_method.toUpperCase()}
/{isCloudHosted() || workspaced_route || globalHttpWorkspacedRoute
? workspace_id + '/' + route_path
: route_path}
{/if}{hasDraft ? '*' : ''}
</div>
<div class="text-secondary text-xs truncate text-left font-normal">
{path}
</div>
<div class="text-secondary text-xs truncate text-left font-normal">
{#if static_asset_config}
file: {static_asset_config.s3}
{:else}
runnable: {script_path}
{/if}
</div>
</a>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
</div>
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
<DraftBadge {draft_only} is_draft={hasDraft} />
<TriggerModeToggle
disabled={draft_only}
title={draft_only
? 'Draft only: deploy the trigger to enable it'
: hasDraft
? 'Enables/disables the deployed trigger; the draft is not affected'
: undefined}
onToggleMode={(mode) => onToggleMode(path, mode)}
triggerMode={effectiveMode}
includeModalConfig={{
triggerPath: path,
triggerKind: 'http',
runnableConfig: {
path: script_path,
kind: is_flow ? 'flow' : 'script',
retry,
errorHandlerPath: error_handler_path,
errorHandlerArgs: error_handler_args
}
}}
{canWrite}
hideToggleLabels
hideDropdown
/>
</div>
<div class="flex gap-2 items-center justify-end">
<Button
on:click={() =>
copyToClipboard(
getHttpRoute(
'r',
route_path,
(workspaced_route ?? false) || globalHttpWorkspacedRoute,
workspace_id
)
)}
variant="subtle"
unifiedSize="md"
startIcon={{ icon: ClipboardCopy }}
>
Copy URL
</Button>
<Button
on:click={() => routeEditor?.openEdit(path, is_flow)}
unifiedSize="md"
startIcon={canWrite
? { icon: Pen }
: {
icon: Eye
}}
variant="subtle"
>
{canWrite ? 'Edit' : 'View'}
</Button>
<Dropdown
size="md"
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
goto(href)
}
},
...(canWrite && !draft_only && mode !== 'suspended'
? [
{
displayName: 'Suspend job execution',
icon: Pause,
action: () => {
onToggleMode(path, 'suspended')
}
}
]
: []),
{
displayName: canWrite ? 'Edit' : 'View',
icon: canWrite ? Pen : Eye,
action: () => {
routeEditor?.openEdit(path, is_flow)
}
},
...(isDeployable('trigger', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer?.openDrawer(path, 'trigger', {
triggers: {
kind: 'routes'
}
})
}
}
]
: []),
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`
},
{
displayName: 'Permissions',
icon: Shield,
action: () => {
shareModal?.openDrawer(path, 'http_trigger')
}
},
{
displayName: 'Delete',
type: 'delete',
icon: Trash,
disabled: !canWrite,
action: async () => {
try {
await HttpTriggerService.deleteHttpTrigger({
workspace: $workspaceStore ?? '',
path
})
sendUserToast(`Successfully deleted HTTP route: ${path}`)
loadTriggers()
} catch (error) {
sendUserToast(error.body || error.message, true)
}
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-end items-baseline">
<div
class="flex flex-wrap text-2xs font-normal text-secondary gap-1 items-center justify-end truncate pr-2"
>
{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}
<div class="truncate">{edited_by ? 'at ' : ''}{displayDate(edited_at)}</div>
</div>
</div>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
<span class="text-xs font-normal text-primary"
>{nbDisplayed} items out of {items.length}
<button class="ml-4 font-semibold text-emphasis" onclick={() => (nbDisplayed += 30)}
>load 30 more</button
></span
>
{/if}
</CenteredPage>
{/if}
<ShareModal
bind:this={shareModal}
on:change={() => {
loadTriggers()
}}
/>
<TriggerList triggerKind="http" />
@@ -1,683 +1,5 @@
<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import {
ScheduleService,
type ScheduleWJobs,
type WorkspaceDeployUISettings,
WorkspaceService
} from '$lib/gen'
import { canWrite, displayDate, getLocalSetting, storeLocalSetting } from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { base } from '$app/paths'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
import Dropdown from '$lib/components/DropdownV2.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import Popover from '$lib/components/Popover.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { userStore, workspaceStore, userWorkspaces, enterpriseLicense } from '$lib/stores'
import {
Calendar,
Circle,
Copy,
Eye,
FileUp,
List,
Loader2,
Pen,
Play,
Plus,
SearchX,
Shield,
Trash
} from 'lucide-svelte'
import { goto } from '$lib/navigation'
import { sendUserToast } from '$lib/toast'
import FilterSearchbar, {
hasActiveFilters,
useUrlSyncedFilterInstance
} from '$lib/components/FilterSearchbar.svelte'
import { buildSchedulesFilterSchema } from '$lib/components/schedules/schedulesFilter'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import JobPreview from '$lib/components/jobs/JobPreview.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { untrack } from 'svelte'
import { page } from '$app/stores'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
import { runScheduleNow } from '$lib/components/triggers/scheduled/utils'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
type ScheduleW = ScheduleWJobs & { canWrite: boolean }
let schedules: ScheduleW[] = $state([])
let shareModal: ShareModal | undefined = $state()
let loading = $state(true)
let loadingSchedulesWithJobStats = $state(true)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
async function loadSchedules(): Promise<void> {
const currentFilters = filters.val
// Build API parameters from filters.
// `includeDraftOnly` surfaces per-user drafts at paths that have
// no deployed schedule — appended server-side when no narrowing
// filter is set, so the user sees AI-agent-created drafts on
// the home page.
const apiParams: any = {
workspace: $workspaceStore!,
includeDraftOnly: true
}
if (currentFilters.schedule_path) {
apiParams.schedulePath = currentFilters.schedule_path
}
if (currentFilters.path_start) {
apiParams.pathStart = currentFilters.path_start
}
if (currentFilters.path) {
apiParams.path = currentFilters.path
}
if (currentFilters.description) {
apiParams.description = currentFilters.description
}
if (currentFilters.summary) {
apiParams.summary = currentFilters.summary
}
if (currentFilters.args) {
apiParams.args = currentFilters.args
}
if (currentFilters._default_) {
apiParams.broadFilter = currentFilters._default_
}
if (currentFilters.label) {
apiParams.label = currentFilters.label
}
const result = (await ScheduleService.listSchedules(apiParams)).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
// Extract unique values for autocomplete
allPaths = Array.from(new Set(result.map((x) => x.path))).sort()
allScriptPaths = Array.from(new Set(result.map((x) => x.script_path))).sort()
allLabels = Array.from(
new Set(result.flatMap((x) => [...(x.labels ?? []), ...(x.inherited_labels ?? [])]))
).sort()
schedules = result
loading = false
// after the schedule core data has been loaded, load all the job stats
// TODO: we could potentially not reload the job stats on every call to loadSchedules, but for now it's
// simpler to always call it. Update if performance becomes an issue.
loadSchedulesWithJobStats()
}
// Reload schedules when filters change
$effect(() => {
filters.val
if ($workspaceStore) {
untrack(() => loadSchedules())
}
})
async function loadSchedulesWithJobStats(): Promise<void> {
loadingSchedulesWithJobStats = true
let schedulesWithJobsByPath = new Map<string, ScheduleW>()
let schedulesWithJobsList = await ScheduleService.listSchedulesWithJobs({
workspace: $workspaceStore!
})
schedulesWithJobsList.map((x) => {
schedulesWithJobsByPath[x.path] = x
})
for (let schedule of schedules) {
if (schedulesWithJobsByPath[schedule.path]) {
schedule.jobs = schedulesWithJobsByPath[schedule.path].jobs
}
}
loadingSchedulesWithJobStats = false
}
// Per-path counter bumped when a schedule toggle is cancelled or errors,
// to force-remount that row's <Toggle>. Toggle uses `bind:checked` on
// its native input; once the user clicks, the local checkbox state
// diverges from the parent's prop expression, and Svelte 5 prop
// reactivity won't push a same-valued prop back down. Re-mounting
// re-initializes from the prop. List-page rows don't optimistically
// flip `enabled`, so they need this nudge — but only the affected row,
// not all rows on the page.
let toggleResetVersions = $state<Record<string, number>>({})
function bumpToggleReset(path: string) {
toggleResetVersions[path] = (toggleResetVersions[path] ?? 0) + 1
}
async function setScheduleEnabled(path: string, enabled: boolean): Promise<void> {
try {
const ok = await withForkConflictRetry(
(force) =>
ScheduleService.setScheduleEnabled({
path,
workspace: $workspaceStore!,
requestBody: { enabled, force }
}),
'schedule'
)
if (ok) {
loadSchedules()
} else {
// Cancelled — nothing changed on the server, skip the reload
// (which would re-fetch job stats and flash the loading flag)
// and just nudge the toggle back to the prop value.
bumpToggleReset(path)
}
} catch (err) {
sendUserToast(`Cannot ` + (enabled ? 'enable' : 'disable') + ` schedule: ${err.body}`, true)
bumpToggleReset(path)
loadSchedules()
}
}
$effect(() => {
if ($workspaceStore && $userStore) {
untrack(() => {
loadSchedules()
})
}
})
let scheduleEditor: ScheduleEditor | undefined = $state()
// Deep link: #<path> opens that schedule's edit drawer. Tracks the last
// handled hash (not a one-shot flag) so a hash change on the already-mounted
// page (e.g. the AI session preview re-pointing its tab) opens the drawer
// too. Row links pre-set handledHash: their onclick already opens the drawer.
let handledHash = ''
$effect(() => {
const hash = $page.url.hash
if (hash.length <= 1) {
// Navigating away from a drawer target must clear the tracker, or
// re-targeting the same schedule later would be skipped as already handled.
handledHash = ''
return
}
if (hash === handledHash || schedules.length === 0 || !scheduleEditor) return
const path = hash.slice(1)
const schedule = schedules.find((s) => s.path === path)
if (schedule) {
handledHash = hash
scheduleEditor.openEdit(path, schedule.is_flow)
}
})
// Collect unique values for filter autocomplete
let allPaths: string[] = $state([])
let allScriptPaths: string[] = $state([])
let allLabels: string[] = $state([])
// FilterSearchbar setup
let userFoldersFilterType = $derived(
$userStore?.is_super_admin && $userStore.username.includes('@')
? 'only f/*'
: $userStore?.is_admin || $userStore?.is_super_admin
? 'u/username and f/*'
: undefined
)
let schedulesFilterSchema = $derived(
buildSchedulesFilterSchema({
paths: allPaths,
scriptPaths: allScriptPaths,
labels: allLabels,
showUserFoldersFilter: userFoldersFilterType !== undefined,
userFoldersLabel:
userFoldersFilterType === 'only f/*' ? 'Only f/*' : `Only u/${$userStore?.username} and f/*`
})
)
let filters = useUrlSyncedFilterInstance(untrack(() => schedulesFilterSchema))
let activeFilters = $derived(hasActiveFilters(filters.val))
let allFolders = $derived(
Array.from(
new Set(
(schedules ?? [])
.map((x) => x.path.split('/').slice(0, 2).join('/'))
.filter((x) => x.startsWith('f/'))
)
)
.sort()
.map((f) => f.replace(/^f\//, ''))
)
let presets = $derived([
...allFolders.map((f) => ({ name: `f/${f}`, value: `path_start:\\ f/${f}/` })),
...allLabels.map((l) => ({ name: l, value: `label:\\ ${l}` })),
...(schedulesFilterSchema.user_folders_only
? [
{
name: schedulesFilterSchema.user_folders_only.label ?? '?',
value: 'user_folders_only:\\ true'
}
]
: [])
])
let nbDisplayed = $state(15)
let filterEnabledDisabled: 'all' | 'enabled' | 'disabled' = $state('all')
const SCHEDULE_PATH_KIND_FILTER_SETTING = 'schedulePathKindFilter'
let selectedFilterKind = $state(
(getLocalSetting(SCHEDULE_PATH_KIND_FILTER_SETTING) as 'schedule' | 'script_flow') ?? 'schedule'
)
$effect(() => {
storeLocalSetting(SCHEDULE_PATH_KIND_FILTER_SETTING, selectedFilterKind)
})
function filterItemsPathsBaseOnUserFilters(
item: ScheduleW,
selectedFilterKind: 'schedule' | 'script_flow',
userFoldersOnly: boolean
) {
if (userFoldersOnly) {
if (selectedFilterKind === 'schedule') {
return (
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
)
} else {
return (
!item.script_path.startsWith('u/') ||
item.script_path.startsWith('u/' + $userStore?.username + '/')
)
}
} else {
return true
}
}
function filterItemsBasedOnEnabledDisabled(
item: ScheduleW,
filterEnabledDisabled: 'all' | 'enabled' | 'disabled'
) {
// Draft-only rows have nothing deployed, so they read as disabled here
// too — matching the off, disabled toggle the row renders.
const enabled = !item.draft_only && item.enabled
if (filterEnabledDisabled === 'all') return true
if (filterEnabledDisabled === 'enabled') return enabled
if (filterEnabledDisabled === 'disabled') return !enabled
}
// Filter schedules client-side for enabled/disabled and user folders
let filteredItems = $derived.by(() => {
return schedules?.filter(
(x) =>
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, !!filters.val.user_folders_only) &&
filterItemsBasedOnEnabledDisabled(x, filterEnabledDisabled)
)
})
let items = $derived(filteredItems)
import SchedulesList from '$lib/components/schedules/SchedulesList.svelte'
</script>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<ScheduleEditor onUpdate={loadSchedules} bind:this={scheduleEditor} />
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.schedules}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{:else}
<CenteredPage>
<PageHeader
title="Schedules"
tooltip="Trigger Scripts and Flows according to a cron schedule"
documentationLink="https://www.windmill.dev/docs/core_concepts/scheduling"
>
<Button
size="lg"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => scheduleEditor?.openNew(false)}
aiId="schedules-add-schedule"
aiDescription="Add schedule"
>
New schedule
</Button>
</PageHeader>
<div class="w-full h-full flex flex-col">
<div class="flex flex-row items-center justify-end gap-4 pb-4">
<ToggleButtonGroup bind:selected={filterEnabledDisabled} class="w-fit">
{#snippet children({ item })}
<ToggleButton value="all" label="All" {item} />
<ToggleButton value="enabled" label="Enabled" {item} />
<ToggleButton value="disabled" label="Disabled" {item} />
{/snippet}
</ToggleButtonGroup>
<FilterSearchbar
schema={schedulesFilterSchema}
bind:value={filters.val}
class="grow max-w-[26rem]"
{presets}
/>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !schedules?.length}
{#if activeFilters}
<EmptyState
icon={SearchX}
title="No schedules found"
description="No schedule matches the current filters. Try clearing or widening them."
/>
{:else}
<EmptyState
icon={Calendar}
title="No schedules yet"
description="Schedules run a script or flow automatically on a cron expression."
action={{
label: 'Add a schedule',
icon: Plus,
onClick: () => scheduleEditor?.openNew(false),
aiId: 'schedules-empty-add',
aiDescription: 'Add schedule'
}}
/>
{/if}
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as { path, error, summary, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, jobs, paused_until, labels, inherited_labels, draft_only, is_draft } (path)}
{@const hasDraft =
getLocalDraftHint($workspaceStore, 'trigger_schedule', path) ?? is_draft}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
{@const avg_s = jobs
? jobs.reduce((acc, x) => acc + x.duration_ms, 0) / jobs.length
: undefined}
<div
class="bg-surface-tertiary hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-4 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
onclick={() => {
handledHash = `#${path}`
scheduleEditor?.openEdit(path, is_flow)
}}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div
class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate"
>
{summary || script_path}{hasDraft ? '*' : ''}
</div>
<div class="text-secondary text-xs truncate text-left">
schedule: {path}
</div>
</a>
{#if labels?.length}
{#each labels as label}
<Badge
color="blue"
small
class="px-1"
title="Label: {label}"
clickable
onclick={() => {
const arr = (filters.val.label ?? '').split(',').filter(Boolean)
const idx = arr.indexOf(label)
if (idx >= 0) arr.splice(idx, 1)
else arr.push(label)
const newFilters = { ...filters.val }
if (arr.length) newFilters.label = arr.join(',')
else delete newFilters.label
filters.val = newFilters
}}>{label}</Badge
>
{/each}
{/if}
<InheritedLabels labels={inherited_labels} />
{#if paused_until && new Date(paused_until) > new Date()}
<div class="pb-1">
<Badge color="yellow"
>Paused until {new Date(paused_until).toLocaleString()}</Badge
>
</div>
{/if}
<div class="gap-2 items-center hidden md:flex">
<Badge large color="blue">{schedule}</Badge>
<Badge small color="gray">{timezone}</Badge>
</div>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
</div>
<div class="w-10">
{#if error}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle
class="text-red-600 animate-ping absolute inline-flex fill-current"
size={12}
/>
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div>
The schedule disabled itself because there was an error scheduling the
next job: {error}
</div>
{/snippet}
</Popover>
{/if}
</div>
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
<DraftBadge {draft_only} is_draft={hasDraft} />
{#key toggleResetVersions[path] ?? 0}
<Toggle
disabled={draft_only}
options={{
title: draft_only
? 'Draft only: deploy the schedule to enable it'
: hasDraft
? 'Enables/disables the deployed schedule; the draft is not affected'
: undefined
}}
checked={!draft_only && enabled}
on:change={(e) => {
if (canWrite) {
setScheduleEnabled(path, e.detail)
} else {
sendUserToast('not enough permission', true)
// Permission denied — bump the row's reset
// counter so the Toggle remounts back to the
// prop value. Without this, the local
// `bind:checked` flip from the user's click
// stays stuck on.
bumpToggleReset(path)
}
}}
/>
{/key}
</div>
<div class="flex gap-2 items-center justify-end">
<Button
href={`${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true`}
disabled={draft_only}
unifiedSize="md"
startIcon={{ icon: List }}
variant="subtle"
>
Runs
</Button>
<Button
on:click={() => scheduleEditor?.openEdit(path, is_flow)}
size="xs"
startIcon={{ icon: canWrite ? Pen : Eye }}
variant="subtle"
>
{canWrite ? 'Edit' : 'View'}
</Button>
<Dropdown
size="md"
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
goto(href)
}
},
{
displayName: `Duplicate schedule`,
icon: Copy,
action: () => {
scheduleEditor?.openNew(is_flow, script_path, path)
}
},
{
displayName: 'Delete',
type: 'delete',
icon: Trash,
disabled: !canWrite,
action: async () => {
await ScheduleService.deleteSchedule({
workspace: $workspaceStore ?? '',
path
})
loadSchedules()
}
},
{
displayName: canWrite ? 'Edit' : 'View',
icon: canWrite ? Pen : Eye,
action: () => {
scheduleEditor?.openEdit(path, is_flow)
}
},
...(isDeployable('trigger', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer?.openDrawer(path, 'trigger', {
triggers: {
kind: 'schedules'
}
})
}
}
]
: []),
{
displayName: 'View runs',
icon: List,
href: `${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true`
},
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`
},
{
displayName: 'Run now',
icon: Play,
action: () => {
runScheduleNow(script_path, path, is_flow, $workspaceStore!)
}
},
{
displayName: 'Permissions',
icon: Shield,
action: () => {
shareModal?.openDrawer(path, 'schedule')
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
{#if loadingSchedulesWithJobStats}
<div class="flex gap-1 ml-0.5 text-xs text-secondary items-center">
<Loader2 size={14} class="animate-spin" />
<span>Job stats loading...</span>
</div>
{:else}
<div class="flex gap-1.5 ml-0.5 items-baseline flex-row-reverse">
{#if avg_s}
<div class="pl-2 text-secondary text-xs"
>Avg: {(avg_s / 1000).toFixed(2)}s</div
>
{/if}
{#each jobs ?? [] as job}
{@const h = (avg_s ? job.duration_ms / avg_s : 1) * 7 + 3}
<a href="{base}/run/{job.id}?workspace={$workspaceStore}">
<JobPreview id={job.id} class="p-4">
<div>
<div
class="{job.success ? 'bg-green-300' : 'bg-red-300'} mx-auto w-1.5"
style="height: {h}px"
></div>
<!-- <div class="text-[0.6em] mt-0.5 text-center text-primary"
>{(job.duration_ms / 1000).toFixed(2)}s</div
> -->
</div>
</JobPreview>
</a>
{/each}
</div>
{/if}
<div
class="flex flex-wrap text-xs text-secondary gap-1 items-center justify-end truncate pr-2"
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
class="truncate">{edited_by ? 'the ' : ''}{displayDate(edited_at)}</div
></div
></div
>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
<div class="flex items-center gap-4 text-xs font-semibold text-emphasis">
<span>{nbDisplayed} items out of {items.length}</span>
<Button size="xs" variant="subtle" on:click={() => (nbDisplayed += 30)}>
Load 30 more
</Button>
</div>
{/if}
</CenteredPage>
{/if}
<ShareModal
bind:this={shareModal}
on:change={() => {
loadSchedules()
}}
/>
<SchedulesList />
@@ -71,7 +71,7 @@
type PreviewTarget
} from '$lib/components/sessions/previewRouter'
import { toolReloadEffect, tabsToReload } from '$lib/components/sessions/previewReload'
import { pageItemForListPath } from '$lib/components/sessions/previewPaths'
import { isPageItemListPath, stripBase } from '$lib/components/sessions/previewPaths'
import {
leafKeyFor,
loadKind,
@@ -834,12 +834,11 @@
owner?.navigate({ type: 'item', item })
return
}
// A row opened on a list page inside a preview tab: its editor opens as a tab of its
// own, leaving the list where it is.
if (d.type === 'wm.session.openPageItem') {
if (typeof d.pagePath !== 'string' || typeof d.path !== 'string') return
const ref = pageItemForListPath(d.pagePath, d.path)
if (ref) owner?.open({ type: 'pageitem', ref })
// A preview frame navigating to a list page: re-point the active tab to the list
// mounted in process, whose rows open as tabs of their own.
if (d.type === 'wm.session.openList') {
if (typeof d.href !== 'string' || !isPageItemListPath(stripBase(d.href))) return
owner?.navigate({ type: 'page', href: d.href, label: previewLocationLabel(d.href) })
return
}
// A job clicked inside a preview tab: open the run detail in a NEW tab so the
@@ -1,567 +1,5 @@
<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import { run } from 'svelte/legacy'
import {
SqsTriggerService,
WorkspaceService,
type SqsTrigger,
type TriggerMode,
type WorkspaceDeployUISettings
} from '$lib/gen'
import {
canWrite,
displayDate,
getLocalSetting,
sendUserToast,
storeLocalSetting,
removeTriggerKindIfUnused
} from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { base } from '$app/paths'
import { page } from '$app/stores'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
import Dropdown from '$lib/components/DropdownV2.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { enterpriseLicense, usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { Code, Eye, Pen, Plus, Shield, Trash, Circle, FileUp, Pause } from 'lucide-svelte'
import { goto } from '$lib/navigation'
import SearchItems from '$lib/components/SearchItems.svelte'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import ListFilters from '$lib/components/home/ListFilters.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { setQuery } from '$lib/navigation'
import { onDestroy, onMount } from 'svelte'
import Popover from '$lib/components/Popover.svelte'
import { isCloudHosted } from '$lib/cloud'
import SqsTriggerEditor from '$lib/components/triggers/sqs/SqsTriggerEditor.svelte'
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import AwsIcon from '$lib/components/icons/AwsIcon.svelte'
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
type TriggerD = SqsTrigger & { canWrite: boolean }
let triggers: TriggerD[] = $state([])
let shareModal: ShareModal | undefined = $state()
let loading = $state(true)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
async function loadTriggers(): Promise<void> {
triggers = (
await SqsTriggerService.listSqsTriggers({
workspace: $workspaceStore!,
includeDraftOnly: true
})
).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'sqs', $usedTriggerKinds)
loading = false
}
let interval = setInterval(async () => {
try {
const newTriggers = await SqsTriggerService.listSqsTriggers({
workspace: $workspaceStore!
})
for (let i = 0; i < triggers.length; i++) {
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
if (newTrigger) {
triggers[i] = {
...triggers[i],
error: newTrigger.error,
last_server_ping: newTrigger.last_server_ping,
mode: newTrigger.mode,
server_id: newTrigger.server_id
}
}
}
} catch (err) {
console.error(err)
}
}, 5000)
onDestroy(() => {
clearInterval(interval)
})
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
let committed = false
try {
const ok = await withForkConflictRetry(
(force) =>
SqsTriggerService.setSqsTriggerMode({
path,
workspace: $workspaceStore!,
requestBody: { mode, force }
}),
'SQS trigger'
)
committed = ok
if (ok) loadTriggers()
} catch (err) {
sendUserToast(
`Cannot ` +
(mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend') +
` sqs trigger: ${err.body}`,
true
)
loadTriggers()
}
return committed
}
run(() => {
if ($workspaceStore && $userStore) {
loadTriggers()
}
})
let sqsTriggerEditor: SqsTriggerEditor | undefined = $state()
let hashHandled = false
$effect(() => {
if (!hashHandled && triggers.length > 0 && sqsTriggerEditor) {
let hash = $page.url.hash
if (hash.length > 1) {
let path = hash.slice(1)
let trigger = triggers.find((t) => t.path === path)
if (trigger) {
hashHandled = true
sqsTriggerEditor?.openEdit(path, trigger.is_flow)
}
}
}
})
let filteredItems: (TriggerD & { marked?: any })[] | undefined = $state([])
let items: typeof filteredItems | undefined = $state([])
let preFilteredItems: typeof filteredItems | undefined = $state([])
let filter = $state('')
let ownerFilter: string | undefined = $state(undefined)
let nbDisplayed = $state(15)
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
let selectedFilterKind = $state(
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
)
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
run(() => {
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
})
run(() => {
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
})
function filterItemsPathsBaseOnUserFilters(
item: TriggerD,
selectedFilterKind: 'trigger' | 'script_flow',
filterUserFolders: boolean
) {
if ($workspaceStore == 'admins') return true
if (filterUserFolders) {
if (selectedFilterKind === 'trigger') {
return (
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
)
} else {
return (
!item.script_path.startsWith('u/') ||
item.script_path.startsWith('u/' + $userStore?.username + '/')
)
}
} else {
return true
}
}
run(() => {
preFilteredItems =
ownerFilter != undefined
? selectedFilterKind === 'trigger'
? triggers?.filter(
(x) =>
x.path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter(
(x) =>
x.script_path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter((x) =>
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
})
run(() => {
if ($workspaceStore) {
ownerFilter = undefined
}
})
let owners = $derived(
selectedFilterKind === 'trigger'
? Array.from(
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
: Array.from(
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
)
run(() => {
items = filter !== '' ? filteredItems : preFilteredItems
})
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
setQuery(
TRIGGER_PATH_KIND_FILTER_SETTING,
selectedFilterKind,
window.location.hash || undefined
).then(() => {
setQuery(
FILTER_USER_FOLDER_SETTING_NAME,
String(filterUserFolders),
window.location.hash || undefined
)
})
}
function loadQueryFilters() {
let url = new URL(window.location.href)
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
if (queryFilterKind) {
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
}
if (queryFilterUserFolders) {
filterUserFolders = queryFilterUserFolders == 'true'
}
}
onMount(() => {
loadQueryFilters()
})
run(() => {
updateQueryFilters(selectedFilterKind, filterUserFolders)
})
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
</script>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<SqsTriggerEditor onUpdate={loadTriggers} bind:this={sqsTriggerEditor} />
<SearchItems
{filter}
items={preFilteredItems}
bind:filteredItems
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
/>
<CenteredPage>
<PageHeader title="SQS triggers" tooltip="SQS trigger">
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => sqsTriggerEditor?.openNew(false)}
>
New&nbsp;SQS trigger
</Button>
</PageHeader>
{#if isCloudHosted()}
<Alert title="Not compatible with multi-tenant cloud" type="warning">
SQS triggers are disabled in the multi-tenant cloud.
</Alert>
<div class="py-4"></div>
{/if}
<div class="w-full h-full flex flex-col">
<div class="w-full pb-4 pt-6">
<input
type="text"
placeholder="Search SQS triggers"
bind:value={filter}
class="search-item"
/>
<div class="flex flex-row items-center gap-2 mt-2">
<div class="text-sm shrink-0"> Filter by path of </div>
<ToggleButtonGroup bind:selected={selectedFilterKind}>
{#snippet children({ item })}
<ToggleButton value="trigger" label="SQS trigger" icon={AwsIcon} {item} />
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
{/snippet}
</ToggleButtonGroup>
</div>
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
<div class="flex flex-row items-center justify-end gap-4">
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
<Toggle
size="xs"
bind:checked={filterUserFolders}
options={{ right: `Only u/${$userStore.username} and f/*` }}
/>
{/if}
</div>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !triggers?.length}
<EmptyState
icon={AwsIcon}
title="No SQS triggers yet"
description="Windmill can consume messages from an SQS queue and trigger scripts or flows on each one."
action={{
label: 'Add an SQS trigger',
icon: Plus,
onClick: () => sqsTriggerEditor?.openNew(false),
aiId: 'sqs-triggers-empty-add',
aiDescription: 'Add SQS trigger'
}}
/>
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as { path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
{@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_sqs', path) ?? is_draft}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
{@const ping = new Date()}
{@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000}
{@const effectiveMode = draft_only ? 'disabled' : mode}
{@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'}
<div
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-5 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
onclick={() => sqsTriggerEditor?.openEdit(path, is_flow)}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate">
{path}{hasDraft ? '*' : ''}
</div>
<div class="text-secondary text-xs truncate text-left font-light"></div>
<div class="text-secondary text-xs truncate text-left font-light">
runnable: {script_path}
</div>
</a>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
{#if labels?.length}
{#each labels as label}
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
{/each}
{/if}
</div>
<div class="w-10">
{#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle
class="text-red-600 animate-ping absolute inline-flex fill-current"
size={12}
/>
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div>
{#if enabled}
{#if !server_id}
SQS trigger is starting...
{:else}
Could not connect to SQS{error ? ': ' + error : ''}
{/if}
{:else}
Disabled because of an error: {error}
{/if}
</div>
{/snippet}
</Popover>
{:else if enabled}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle class="text-green-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div> Connected to SQS{!server_id ? ' (shutting down...)' : ''}</div>
{/snippet}
</Popover>
{/if}
</div>
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
<DraftBadge {draft_only} is_draft={hasDraft} />
<TriggerModeToggle
disabled={draft_only}
title={draft_only
? 'Draft only: deploy the trigger to enable it'
: hasDraft
? 'Enables/disables the deployed trigger; the draft is not affected'
: undefined}
onToggleMode={(newMode) => onToggleMode(path, newMode)}
triggerMode={effectiveMode}
includeModalConfig={{
triggerPath: path,
triggerKind: 'sqs',
runnableConfig: {
path: script_path,
kind: is_flow ? 'flow' : 'script',
retry,
errorHandlerPath: error_handler_path,
errorHandlerArgs: error_handler_args
}
}}
{canWrite}
hideToggleLabels
hideDropdown
/>
</div>
<div class="flex gap-2 items-center justify-end">
<Button
on:click={() => sqsTriggerEditor?.openEdit(path, is_flow)}
size="xs"
startIcon={canWrite
? { icon: Pen }
: {
icon: Eye
}}
variant="subtle"
>
{canWrite ? 'Edit' : 'View'}
</Button>
<Dropdown
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
goto(href)
}
},
...(canWrite && !draft_only && mode !== 'suspended'
? [
{
displayName: 'Suspend job execution',
icon: Pause,
action: () => {
onToggleMode(path, 'suspended')
}
}
]
: []),
{
displayName: canWrite ? 'Edit' : 'View',
icon: canWrite ? Pen : Eye,
action: () => {
sqsTriggerEditor?.openEdit(path, is_flow)
}
},
...(isDeployable('trigger', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer?.openDrawer(path, 'trigger', {
triggers: {
kind: 'sqs'
}
})
}
}
]
: []),
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`
},
{
displayName: 'Permissions',
icon: Shield,
action: () => {
shareModal?.openDrawer(path, 'sqs_trigger')
}
},
{
displayName: 'Delete',
type: 'delete',
icon: Trash,
disabled: !canWrite,
action: async () => {
try {
await SqsTriggerService.deleteSqsTrigger({
workspace: $workspaceStore ?? '',
path
})
loadTriggers()
} catch (error) {
sendUserToast(error.body, true)
}
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
<div
class="flex flex-wrap text-[0.7em] text-primary gap-1 items-center justify-end truncate pr-2"
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
class="truncate">{edited_by ? 'the ' : ''}{displayDate(edited_at)}</div
></div
></div
>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
<span class="text-xs"
>{nbDisplayed} items out of {items.length}
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
>
{/if}
</CenteredPage>
<ShareModal
bind:this={shareModal}
on:change={() => {
loadTriggers()
}}
/>
<TriggerList triggerKind="sqs" />
@@ -1,722 +1,5 @@
<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import {
Alert,
Badge,
Button,
EmptyState,
Skeleton,
Tab,
TabFade,
Tabs
} from '$lib/components/common'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import { OauthService, VariableService, WorkspaceService } from '$lib/gen'
import ContextualVariableEditor from '$lib/components/ContextualVariableEditor.svelte'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import Dropdown from '$lib/components/DropdownV2.svelte'
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import Popover from '$lib/components/Popover.svelte'
import FilterSearchbar, {
hasActiveFilters,
useUrlSyncedFilterInstance
} from '$lib/components/FilterSearchbar.svelte'
import { buildVariablesFilterSchema } from '$lib/components/variables/variablesFilter'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Cell from '$lib/components/table/Cell.svelte'
import DataTable from '$lib/components/table/DataTable.svelte'
import Head from '$lib/components/table/Head.svelte'
import Row from '$lib/components/table/Row.svelte'
import TableSimple from '$lib/components/TableSimple.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import VariableEditor from '$lib/components/VariableEditor.svelte'
import type { ContextualVariable, ListableVariable, WorkspaceDeployUISettings } from '$lib/gen'
import { enterpriseLicense, userStore, workspaceStore, userWorkspaces } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { canWrite, isOwner, truncate } from '$lib/utils'
import { isDeployable, ALL_DEPLOYABLE } from '$lib/utils_deployable'
import {
Plus,
FileUp,
Link,
Pen,
RefreshCw,
Shield,
Trash,
Building,
DollarSign,
EyeOff,
Circle,
SearchX
} from 'lucide-svelte'
import { untrack } from 'svelte'
import { page } from '$app/stores'
type ListableVariableW = ListableVariable & { canWrite: boolean }
let variables = $state(undefined) as ListableVariableW[] | undefined
let showCreateButtons = $state(false)
// Collect unique values for filter autocomplete
let allPaths: string[] = $state([])
let allOwners: string[] = $state([])
let allLabels: string[] = $state([])
// FilterSearchbar setup
let userFoldersFilterType = $derived(
$userStore?.is_super_admin && $userStore.username.includes('@')
? 'only f/*'
: $userStore?.is_admin || $userStore?.is_super_admin
? 'u/username and f/*'
: undefined
)
let variablesFilterSchema = $derived(
buildVariablesFilterSchema({
paths: allPaths,
owners: allOwners,
labels: allLabels,
showUserFoldersFilter: userFoldersFilterType !== undefined,
userFoldersLabel:
userFoldersFilterType === 'only f/*' ? 'Only f/*' : `Only u/${$userStore?.username} and f/*`
})
)
let filters = useUrlSyncedFilterInstance(untrack(() => variablesFilterSchema))
let itemFolders = $derived(
Array.from(
new Set(
(variables ?? [])
.map((x) => x.path.split('/').slice(0, 2).join('/'))
.filter((x) => x.startsWith('f/'))
)
)
.sort()
.map((f) => f.replace(/^f\//, ''))
)
let folderPresets = $derived([
...itemFolders.map((f) => ({ name: `f/${f}`, value: `path_start:\\ f/${f}/` })),
...allLabels.map((l) => ({ name: l, value: `label:\\ ${l}` })),
...(variablesFilterSchema.user_folders_only
? [
{
name: variablesFilterSchema.user_folders_only.label ?? '?',
value: 'user_folders_only:\\ true'
}
]
: [])
])
let contextualVariables: ContextualVariable[] = $state([])
let shareModal: ShareModal | undefined = $state()
let variableEditor: VariableEditor | undefined = $state()
let contextualVariableEditor: ContextualVariableEditor | undefined = $state()
let loading = $state({
contextual: true
})
let deleteConfirmedCallback: (() => void) | undefined = $state(undefined)
let deleteIsLinked = $state(false)
let open = $derived(Boolean(deleteConfirmedCallback))
// Filter variables client-side for user folder filtering (admin feature)
let filteredItems = $derived.by(() => {
let items = variables
if (filters.val.user_folders_only && items) {
items = items.filter((item) => {
if (userFoldersFilterType === 'only f/*') return item.path.startsWith('f/')
if (userFoldersFilterType === 'u/username and f/*')
return item.path.startsWith('f/') || item.path.startsWith(`u/${$userStore?.username}/`)
return true
})
}
return items
})
// If relative, the dropdown is positioned relative to its button
async function loadVariables(): Promise<void> {
const currentFilters = filters.val
// Build API parameters from filters.
// `includeDraftOnly` surfaces per-user drafts at paths that have
// no deployed variable — appended server-side when no narrowing
// filter is set, so the user sees AI-agent-created drafts on
// the home page.
const apiParams: any = {
workspace: $workspaceStore!,
includeDraftOnly: true
}
if (currentFilters.path) {
apiParams.path = currentFilters.path
}
if (currentFilters.path_start) {
apiParams.pathStart = currentFilters.path_start
}
if (currentFilters.description) {
apiParams.description = currentFilters.description
}
if (currentFilters.value) {
apiParams.value = currentFilters.value
}
if (currentFilters.owner) {
apiParams.pathStart = currentFilters.owner
}
if (currentFilters._default_) {
apiParams.broadFilter = currentFilters._default_
}
if (currentFilters.label) {
apiParams.label = currentFilters.label
}
const result = (await VariableService.listVariable(apiParams)).map((x) => {
return {
canWrite: canWrite(x.path, x.extra_perms!, $userStore) && x.workspace_id == $workspaceStore,
...x
}
})
// Extract unique values for autocomplete
allPaths = Array.from(new Set(result.map((x) => x.path))).sort()
allOwners = Array.from(
new Set(result.map((x) => x.path.split('/').slice(0, 2).join('/')))
).sort()
allLabels = Array.from(
new Set(result.flatMap((x) => [...(x.labels ?? []), ...(x.inherited_labels ?? [])]))
).sort()
variables = result
}
// Reload variables when filters change
$effect(() => {
filters.val
if ($workspaceStore) {
untrack(() => loadVariables())
}
})
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
async function loadContextualVariables(): Promise<void> {
contextualVariables = await VariableService.listContextualVariables({
workspace: $workspaceStore!
})
loading.contextual = false
}
async function deleteVariable(path: string, account?: number): Promise<void> {
if (account) {
OauthService.disconnectAccount({ workspace: $workspaceStore!, id: account })
}
await VariableService.deleteVariable({ workspace: $workspaceStore!, path })
loadVariables()
sendUserToast(`Variable ${path} was deleted`)
}
$effect(() => {
if ($workspaceStore && $userStore) {
untrack(() => {
loadVariables()
loadContextualVariables()
})
}
})
let tab: 'workspace' | 'contextual' = $state('workspace')
let activeFilters = $derived(hasActiveFilters(filters.val))
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
async function deleteContextualVariable(row: { name: string }) {
await WorkspaceService.setEnvironmentVariable({
workspace: $workspaceStore!,
requestBody: {
name: row.name,
value: undefined
}
})
loadContextualVariables()
sendUserToast(
`Custom contextual variable ${row.name} was deleted. It may take up to a few minutes to update.`
)
setTimeout(() => {
loadContextualVariables()
}, 5000)
}
// Deep link: #<path> opens that variable's edit drawer. Reactive rather than
// onMount so a hash change on the already-mounted page (e.g. the AI session
// preview re-pointing its tab) opens the drawer too. Row links pre-set
// handledHash: their onclick already opens the drawer.
let handledHash = ''
$effect(() => {
const hash = $page.url.hash
// Only item paths are drawer targets: the same hash also carries global
// drawers like #superadmin-settings, which must not be looked up as a variable.
if (!/^#[ufg]\//.test(hash)) {
// Navigating away from a drawer target must clear the tracker, or
// re-targeting the same item later would be skipped as already handled.
handledHash = ''
return
}
if (hash === handledHash || !variableEditor) return
handledHash = hash
variableEditor.editVariable(hash.slice(1))
})
import VariablesList from '$lib/components/variables/VariablesList.svelte'
</script>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.variables}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{:else}
<CenteredPage>
<PageHeader
title="Variables"
tooltip="Save and permission strings to be reused in Scripts and Flows."
documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets"
>
{#if showCreateButtons}
<div class="flex flex-row justify-end">
{#if tab == 'contextual' && ($userStore?.is_admin || $userStore?.is_super_admin)}
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => contextualVariableEditor?.initNew()}
>
New&nbsp;contextual&nbsp;variable
</Button>
{:else}
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => variableEditor?.initNew()}
>
New&nbsp;variable
</Button>
{/if}
</div>
{/if}
</PageHeader>
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
<VariableEditor
bind:this={variableEditor}
workspace={$workspaceStore}
on:create={loadVariables}
/>
<ContextualVariableEditor
bind:this={contextualVariableEditor}
on:update={loadContextualVariables}
/>
<ShareModal
bind:this={shareModal}
on:change={() => {
loadVariables()
}}
/>
<div class="flex gap-2 justify-between items-center">
<Tabs bind:selected={tab}>
<Tab value="workspace" label="Workspace" icon={Building} />
<Tab value="contextual" label="Contextual" icon={DollarSign}>
{#snippet extra()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets#contextual-variables"
>
Contextual variables are passed as environment variables when running a script and
depends on the execution context.
</Tooltip>
{/snippet}
</Tab>
</Tabs>
{#if tab == 'workspace'}
<FilterSearchbar
class="grow max-w-[26rem]"
schema={variablesFilterSchema}
bind:value={filters.val}
placeholder="Filter variables..."
presets={folderPresets}
/>
{/if}
</div>
<TabFade key={tab}>
{#if tab == 'workspace'}
<div class="relative overflow-x-auto pb-40 mt-4">
{#if !filteredItems}
<Skeleton layout={[0.5, [2], 1]} />
{#each new Array(3) as _}
<Skeleton layout={[[3.5], 0.5]} />
{/each}
{:else if filteredItems.length == 0}
{#if activeFilters}
<EmptyState
icon={SearchX}
title="No variables found"
description="No variable matches the current filters. Try clearing or widening them."
/>
{:else}
<EmptyState
icon={DollarSign}
title="No variables yet"
description="Variables store values and secrets that your scripts, flows and apps can read at runtime."
action={showCreateButtons
? {
label: 'Add a variable',
icon: Plus,
onClick: () => variableEditor?.initNew(),
aiId: 'variables-empty-add-variable',
aiDescription: 'Add variable'
}
: undefined}
/>
{/if}
{:else}
<DataTable size="xs">
<Head>
<tr>
<Cell head first class="!px-0" />
<Cell head>Path</Cell>
<Cell head>Value</Cell>
<Cell head>Description</Cell>
<Cell head>Status</Cell>
<Cell head last actions>Actions</Cell>
</tr>
</Head>
<tbody class="divide-y">
{#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft }}
{@const hasDraft =
getLocalDraftHint($workspaceStore, 'variable', path) ?? is_draft}
<Row>
<Cell class="!px-0 text-center w-12" first>
<SharedBadge {canWrite} extraPerms={extra_perms} />
</Cell>
<Cell>
<div class="flex items-center gap-2">
<a
class="break-all"
id="edit-{path}"
onclick={() => {
handledHash = `#${path}`
variableEditor?.editVariable(path)
}}
href="#{path}"
>
{path}{hasDraft ? '*' : ''}
</a>
<DraftBadge {draft_only} is_draft={hasDraft} />
{#if labels?.length}
{#each labels as label}
<Badge
color="blue"
small
class="px-1"
title="Label: {label}"
clickable
onclick={() => {
const arr = (filters.val.label ?? '').split(',').filter(Boolean)
const idx = arr.indexOf(label)
if (idx >= 0) arr.splice(idx, 1)
else arr.push(label)
const newFilters = { ...filters.val }
if (arr.length) newFilters.label = arr.join(',')
else delete newFilters.label
filters.val = newFilters
}}>{label}</Badge
>
{/each}
{/if}
<InheritedLabels labels={inherited_labels} />
</div>
</Cell>
<Cell>
<span class="inline-flex flex-row items-center gap-2">
<div class="text-xs break-words">
{#if value}
{truncate(value, 20)}
{:else}
&lowast;&lowast;&lowast;&lowast;
{/if}
</div>
{#if is_secret}
<Popover notClickable>
<EyeOff size={12} />
{#snippet text()}
<span>This item is secret</span>
{/snippet}
</Popover>
{/if}
</span>
</Cell>
<Cell class="break-words">
<span class="text-xs text-primary">{truncate(description ?? '', 50)} </span>
</Cell>
<Cell class="text-center">
<div class="flex flex-row items-center gap-4">
{#if is_linked}
<Popover notClickable>
<Link size={16} />
{#snippet text()}
<div>
This variable is linked with a resource of the same path. They are
deleted and renamed together.
</div>
{/snippet}
</Popover>
{/if}
{#if account}
<Popover notClickable>
<RefreshCw size={16} />
{#snippet text()}
<div>
This OAuth token will be kept up-to-date in the background by
Windmill using its refresh token
</div>
{/snippet}
</Popover>
{/if}
{#if is_refreshed}
<div class="">
{#if refresh_error}
<Popover notClickable>
<!-- isolate: confine the ping indicator's z-50 to a local stacking context
so it can't paint over anything that scrolls past it -->
<div
class="relative inline-flex justify-center items-center w-4 h-4 isolate"
>
<Circle
class="text-red-600 animate-ping absolute z-50 w-4 h-4 fill-current"
size={12}
/>
<Circle
class="text-red-600 relative inline-flex fill-current "
size={12}
/>
</div>
{#snippet text()}
<div>
Latest exchange of the refresh token did not succeed. Error: {refresh_error}
</div>
{/snippet}
</Popover>
{:else if is_expired}
<Popover notClickable>
<Circle
class="text-yellow-600 animate-[pulse_5s_linear_infinite] fill-current"
size={12}
/>
{#snippet text()}
<div>
The access_token is expired, it will get renewed the next time
this variable is fetched or you can request is to be refreshed
in the dropdown on the right.
</div>
{/snippet}
</Popover>
{:else}
<Popover notClickable>
<Circle
class="text-green-600 animate-[pulse_5s_linear_infinite] fill-current"
size={12}
/>
{#snippet text()}
<div>
The variable was connected through OAuth and the token is not
expired.
</div>
{/snippet}
</Popover>
{/if}
</div>
{/if}
</div>
</Cell>
<Cell last actions shouldStopPropagation>
<Dropdown
items={() => {
let owner = isOwner(path, $userStore, $workspaceStore)
return [
{
displayName: 'Edit',
icon: Pen,
action: () => variableEditor?.editVariable(path),
disabled: !canWrite || !showCreateButtons
},
{
displayName: 'Delete',
icon: Trash,
type: 'delete',
action: (event) => {
if (event['shiftKey']) {
deleteVariable(path, account)
} else {
deleteIsLinked = is_linked ?? false
deleteConfirmedCallback = () => {
deleteVariable(path, account)
}
}
},
disabled: !owner || !showCreateButtons
},
...(!ws_specific &&
isDeployable(is_secret ? 'secret' : 'variable', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer?.openDrawer(path, 'variable')
}
}
]
: []),
{
displayName: 'Permissions',
action: () => {
shareModal?.openDrawer(path, 'variable')
},
icon: Shield
},
...(account != undefined
? [
{
displayName: 'Refresh token',
icon: RefreshCw,
action: async () => {
await OauthService.refreshToken({
workspace: $workspaceStore ?? '',
id: account ?? 0,
requestBody: {
path
}
})
sendUserToast('Token refreshed')
loadVariables()
}
}
]
: [])
]
}}
/>
</Cell>
</Row>
{/each}
</tbody>
</DataTable>
{/if}
</div>
{:else if tab == 'contextual'}
<div class="overflow-auto">
{#if loading.contextual}
<Skeleton layout={[0.5, [2], 1]} />
{#each new Array(8) as _}
<Skeleton layout={[[2.8], 0.5]} />
{/each}
{:else}
<PageHeader title="Custom contextual variables" primary={false} />
{#if contextualVariables.filter((x) => x.is_custom).length === 0}
<EmptyState
icon={DollarSign}
title="No custom contextual variables yet"
description="Custom contextual variables are passed as environment variables to every script run in this workspace."
action={showCreateButtons && ($userStore?.is_admin || $userStore?.is_super_admin)
? {
label: 'Add a contextual variable',
icon: Plus,
onClick: () => contextualVariableEditor?.initNew(),
aiId: 'variables-empty-add-contextual-variable',
aiDescription: 'Add contextual variable'
}
: undefined}
/>
{:else}
<TableSimple
headers={['Name', 'Value']}
data={contextualVariables.filter((x) => x.is_custom)}
keys={['name', 'value']}
getRowActions={$userStore?.is_admin || $userStore?.is_super_admin
? (row) => {
return [
{
displayName: 'Edit',
action: () => contextualVariableEditor?.editVariable(row.name, row.value)
},
{
displayName: 'Delete',
type: 'delete',
action: () => {
deleteContextualVariable(row)
}
}
]
}
: undefined}
/>
{/if}
<PageHeader title="Contextual variables" primary={false} />
<TableSimple
headers={['Name', 'Example of value', 'Description']}
data={contextualVariables.filter((x) => !x.is_custom)}
keys={['name', 'value', 'description']}
/>
{/if}
</div>
{/if}
</TabFade>
</CenteredPage>
{/if}
<ConfirmationModal
{open}
title="Remove variable"
confirmationText="Remove"
trashbin
on:canceled={() => {
deleteConfirmedCallback = undefined
}}
on:confirmed={() => {
if (deleteConfirmedCallback) {
deleteConfirmedCallback()
}
deleteConfirmedCallback = undefined
}}
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to remove this variable?</span>
{#if deleteIsLinked}
<Alert type="warning" title="Linked resource">
This variable is linked with a resource of the same path. The linked resource will also be
deleted.
</Alert>
{/if}
<Alert type="info" title="Bypass confirmation">
<div>
You can press
<Badge color="dark-gray">SHIFT</Badge>
while removing a variable to bypass confirmation.
</div>
</Alert>
</div>
</ConfirmationModal>
<VariablesList />
@@ -1,579 +1,5 @@
<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import { run } from 'svelte/legacy'
import {
WebsocketTriggerService,
WorkspaceService,
type WebsocketTrigger,
type TriggerMode,
type WorkspaceDeployUISettings
} from '$lib/gen'
import {
canWrite,
displayDate,
getLocalSetting,
sendUserToast,
storeLocalSetting,
removeTriggerKindIfUnused,
capitalize
} from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { base } from '$app/paths'
import { page } from '$app/stores'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
import Dropdown from '$lib/components/DropdownV2.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { enterpriseLicense, usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { Unplug, Code, Eye, Pen, Plus, Shield, Trash, Circle, FileUp, Pause } from 'lucide-svelte'
import { goto } from '$lib/navigation'
import SearchItems from '$lib/components/SearchItems.svelte'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import ListFilters from '$lib/components/home/ListFilters.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { setQuery } from '$lib/navigation'
import { onDestroy, onMount } from 'svelte'
import Popover from '$lib/components/Popover.svelte'
import { isCloudHosted } from '$lib/cloud'
import WebsocketTriggerEditor from '$lib/components/triggers/websocket/WebsocketTriggerEditor.svelte'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
type TriggerW = WebsocketTrigger & { canWrite: boolean }
let triggers: TriggerW[] = $state([])
let shareModal: ShareModal | undefined = $state()
let loading = $state(true)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
async function loadTriggers(): Promise<void> {
triggers = (
await WebsocketTriggerService.listWebsocketTriggers({
workspace: $workspaceStore!,
includeDraftOnly: true
})
).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'websockets', $usedTriggerKinds)
loading = false
}
let interval = setInterval(async () => {
try {
const newTriggers = await WebsocketTriggerService.listWebsocketTriggers({
workspace: $workspaceStore!
})
for (let i = 0; i < triggers.length; i++) {
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
if (newTrigger) {
triggers[i] = {
...triggers[i],
error: newTrigger.error,
last_server_ping: newTrigger.last_server_ping,
mode: newTrigger.mode,
server_id: newTrigger.server_id
}
}
}
} catch (err) {
console.error(err)
}
}, 5000)
onDestroy(() => {
clearInterval(interval)
})
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
let committed = false
try {
const ok = await withForkConflictRetry(
(force) =>
WebsocketTriggerService.setWebsocketTriggerMode({
path,
workspace: $workspaceStore!,
requestBody: { mode, force }
}),
'websocket trigger'
)
if (ok) {
sendUserToast(`${capitalize(mode)} websocket trigger ${path}`)
loadTriggers()
}
committed = ok
} catch (err) {
sendUserToast(
`Cannot ` +
(mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend') +
` websocket trigger: ${err.body}`,
true
)
loadTriggers()
}
return committed
}
run(() => {
if ($workspaceStore && $userStore) {
loadTriggers()
}
})
let websocketTriggerEditor: WebsocketTriggerEditor | undefined = $state()
let hashHandled = false
$effect(() => {
if (!hashHandled && triggers.length > 0 && websocketTriggerEditor) {
let hash = $page.url.hash
if (hash.length > 1) {
let path = hash.slice(1)
let trigger = triggers.find((t) => t.path === path)
if (trigger) {
hashHandled = true
websocketTriggerEditor?.openEdit(path, trigger.is_flow)
}
}
}
})
let filteredItems: (TriggerW & { marked?: any })[] | undefined = $state([])
let items: typeof filteredItems | undefined = $state([])
let preFilteredItems: typeof filteredItems | undefined = $state([])
let filter = $state('')
let ownerFilter: string | undefined = $state(undefined)
let nbDisplayed = $state(15)
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
let selectedFilterKind = $state(
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
)
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
run(() => {
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
})
run(() => {
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
})
function filterItemsPathsBaseOnUserFilters(
item: TriggerW,
selectedFilterKind: 'trigger' | 'script_flow',
filterUserFolders: boolean
) {
if ($workspaceStore == 'admins') return true
if (filterUserFolders) {
if (selectedFilterKind === 'trigger') {
return (
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
)
} else {
return (
!item.script_path.startsWith('u/') ||
item.script_path.startsWith('u/' + $userStore?.username + '/')
)
}
} else {
return true
}
}
run(() => {
preFilteredItems =
ownerFilter != undefined
? selectedFilterKind === 'trigger'
? triggers?.filter(
(x) =>
x.path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter(
(x) =>
x.script_path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter((x) =>
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
})
run(() => {
if ($workspaceStore) {
ownerFilter = undefined
}
})
let owners = $derived(
selectedFilterKind === 'trigger'
? Array.from(
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
: Array.from(
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
)
run(() => {
items = filter !== '' ? filteredItems : preFilteredItems
})
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
setQuery(
TRIGGER_PATH_KIND_FILTER_SETTING,
selectedFilterKind,
window.location.hash || undefined
).then(() => {
setQuery(
FILTER_USER_FOLDER_SETTING_NAME,
String(filterUserFolders),
window.location.hash || undefined
)
})
}
function loadQueryFilters() {
let url = new URL(window.location.href)
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
if (queryFilterKind) {
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
}
if (queryFilterUserFolders) {
filterUserFolders = queryFilterUserFolders == 'true'
}
}
onMount(() => {
loadQueryFilters()
})
run(() => {
updateQueryFilters(selectedFilterKind, filterUserFolders)
})
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
</script>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<WebsocketTriggerEditor onUpdate={loadTriggers} bind:this={websocketTriggerEditor} />
<SearchItems
{filter}
items={preFilteredItems}
bind:filteredItems
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
/>
<CenteredPage>
<PageHeader
title="WebSocket triggers"
tooltip="Windmill can listen to WebSocket events and trigger scripts or flows based on them."
>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => websocketTriggerEditor?.openNew(false)}
>
New&nbsp;WebSocket trigger
</Button>
</PageHeader>
{#if isCloudHosted()}
<Alert title="Not compatible with multi-tenant cloud" type="warning">
WebSocket triggers are disabled in the multi-tenant cloud.
</Alert>
<div class="py-4"></div>
{/if}
<div class="w-full h-full flex flex-col">
<div class="w-full pb-4 pt-6">
<input type="text" placeholder="Search WS triggers" bind:value={filter} class="search-item" />
<div class="flex flex-row items-center gap-2 mt-2">
<div class="text-xs font-semibold text-emphasis shrink-0"> Filter by path of </div>
<ToggleButtonGroup bind:selected={selectedFilterKind}>
{#snippet children({ item })}
<ToggleButton value="trigger" label="WS trigger" icon={Unplug} {item} />
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
{/snippet}
</ToggleButtonGroup>
</div>
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
<div class="flex flex-row items-center justify-end gap-4">
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
<Toggle
size="xs"
bind:checked={filterUserFolders}
options={{ right: `Only u/${$userStore.username} and f/*` }}
/>
{/if}
</div>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !triggers?.length}
<EmptyState
icon={Unplug}
title="No WebSocket triggers yet"
description="Windmill can listen to WebSocket events and trigger scripts or flows based on them."
action={{
label: 'Add a WebSocket trigger',
icon: Plus,
onClick: () => websocketTriggerEditor?.openNew(false),
aiId: 'websocket-triggers-empty-add',
aiDescription: 'Add WebSocket trigger'
}}
/>
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, url, is_flow, extra_perms, canWrite, marked, error, last_server_ping, server_id, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
{@const hasDraft =
getLocalDraftHint($workspaceStore, 'trigger_websocket', path) ?? is_draft}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
{@const ping = last_server_ping ? new Date(last_server_ping) : undefined}
{@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000}
{@const effectiveMode = draft_only ? 'disabled' : mode}
{@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'}
<div
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-5 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
onclick={() => websocketTriggerEditor?.openEdit(path, is_flow)}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate">
{#if marked}
<span class="text-xs">
{@html marked}
</span>
{:else}
{url.startsWith('$script:')
? 'URL: ' + url.replace('$script:', 'result of script ')
: url.startsWith('$flow:')
? 'URL: ' + url.replace('$flow:', 'result of flow ')
: url}
{/if}{hasDraft ? '*' : ''}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
{path}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
runnable: {script_path}
</div>
</a>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
{#if labels?.length}
{#each labels as label}
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
{/each}
{/if}
</div>
<div class="w-10">
{#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle
class="text-red-600 animate-ping absolute inline-flex fill-current"
size={12}
/>
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div>
{#if enabled}
{#if !server_id}
WebSocket is starting...
{:else}
WebSocket is not connected{error ? ': ' + error : ''}
{/if}
{:else}
WebSocket was disabled because of an error: {error}
{/if}
</div>
{/snippet}
</Popover>
{:else if enabled}
<Popover notClickable>
<span class="flex h-4 w-4">
<Circle class="text-green-600 relative inline-flex fill-current" size={12} />
</span>
{#snippet text()}
<div>
WebSocket is connected{!server_id ? ' (shutting down...)' : ''}
</div>
{/snippet}
</Popover>
{/if}
</div>
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
<DraftBadge {draft_only} is_draft={hasDraft} />
<TriggerModeToggle
disabled={draft_only}
title={draft_only
? 'Draft only: deploy the trigger to enable it'
: hasDraft
? 'Enables/disables the deployed trigger; the draft is not affected'
: undefined}
onToggleMode={(newMode) => onToggleMode(path, newMode)}
triggerMode={effectiveMode}
includeModalConfig={{
triggerPath: path,
triggerKind: 'websocket',
runnableConfig: {
path: script_path,
kind: is_flow ? 'flow' : 'script',
retry,
errorHandlerPath: error_handler_path,
errorHandlerArgs: error_handler_args
}
}}
{canWrite}
hideToggleLabels
hideDropdown
/>
</div>
<div class="flex gap-2 items-center justify-end">
<Button
on:click={() => websocketTriggerEditor?.openEdit(path, is_flow)}
size="xs"
startIcon={canWrite
? { icon: Pen }
: {
icon: Eye
}}
variant="subtle"
>
{canWrite ? 'Edit' : 'View'}
</Button>
<Dropdown
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
goto(href)
}
},
...(canWrite && !draft_only && mode !== 'suspended'
? [
{
displayName: 'Suspend job execution',
icon: Pause,
action: () => {
onToggleMode(path, 'suspended')
}
}
]
: []),
{
displayName: canWrite ? 'Edit' : 'View',
icon: canWrite ? Pen : Eye,
action: () => {
websocketTriggerEditor?.openEdit(path, is_flow)
}
},
...(isDeployable('trigger', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer?.openDrawer(path, 'trigger', {
triggers: {
kind: 'websockets'
}
})
}
}
]
: []),
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`
},
{
displayName: 'Permissions',
icon: Shield,
action: () => {
shareModal?.openDrawer(path, 'websocket_trigger')
}
},
{
displayName: 'Delete',
type: 'delete',
icon: Trash,
disabled: !canWrite,
action: async () => {
await WebsocketTriggerService.deleteWebsocketTrigger({
workspace: $workspaceStore ?? '',
path
})
loadTriggers()
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
<div
class="flex flex-wrap text-[0.7em] text-primary gap-1 items-center justify-end truncate pr-2"
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
class="truncate">{edited_by ? 'the ' : ''}{displayDate(edited_at)}</div
></div
></div
>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
<span class="text-xs"
>{nbDisplayed} items out of {items.length}
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
>
{/if}
</CenteredPage>
<ShareModal
bind:this={shareModal}
on:change={() => {
loadTriggers()
}}
/>
<TriggerList triggerKind="websocket" />