diff --git a/backend/.sqlx/query-bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e.json b/backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json
similarity index 82%
rename from backend/.sqlx/query-bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e.json
rename to backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json
index 578edc6044..5c0e45b0d4 100644
--- a/backend/.sqlx/query-bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e.json
+++ b/backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json
@@ -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"
}
diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs
index dfd4a6e3ba..78cd6eb378 100644
--- a/backend/windmill-api-schedule/src/lib.rs
+++ b/backend/windmill-api-schedule/src/lib.rs
@@ -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
diff --git a/frontend/src/lib/components/ContextualVariableEditor.svelte b/frontend/src/lib/components/ContextualVariableEditor.svelte
index 43277787ba..9adde76e74 100644
--- a/frontend/src/lib/components/ContextualVariableEditor.svelte
+++ b/frontend/src/lib/components/ContextualVariableEditor.svelte
@@ -1,7 +1,6 @@
-{#if !$userStore?.operator && activeDeployRulesets.length > 0}
+{#if !operatingUser.current?.operator && activeDeployRulesets.length > 0}
{
- 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.
diff --git a/frontend/src/lib/components/ShareModal.svelte b/frontend/src/lib/components/ShareModal.svelte
index 471ed74099..da6cc9a674 100644
--- a/frontend/src/lib/components/ShareModal.svelte
+++ b/frontend/src/lib/components/ShareModal.svelte
@@ -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 {
- groups = await GroupService.listGroupNames({ workspace: $workspaceStore! })
+ groups = await GroupService.listGroupNames({ workspace: $operatingWorkspace! })
}
async function loadUsernames(): Promise {
- 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 @@
{/if}
-
Extra members ({acls?.length ?? 0})
+
Extra members ({acls?.length ?? 0})
{#if linkedVarPaths.length > 0}
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 {
- 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
diff --git a/frontend/src/lib/components/hostedPage.ts b/frontend/src/lib/components/hostedPage.ts
new file mode 100644
index 0000000000..424c10c450
--- /dev/null
+++ b/frontend/src/lib/components/hostedPage.ts
@@ -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(KEY)
+}
diff --git a/frontend/src/lib/components/jobs/JobPreview.svelte b/frontend/src/lib/components/jobs/JobPreview.svelte
index 972cff6799..7f95ef8f63 100644
--- a/frontend/src/lib/components/jobs/JobPreview.svelte
+++ b/frontend/src/lib/components/jobs/JobPreview.svelte
@@ -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 @@
Arguments
diff --git a/frontend/src/lib/components/resources/ResourcesList.svelte b/frontend/src/lib/components/resources/ResourcesList.svelte
new file mode 100644
index 0000000000..180474276a
--- /dev/null
+++ b/frontend/src/lib/components/resources/ResourcesList.svelte
@@ -0,0 +1,1623 @@
+
+
+
{
+ deleteConfirmedCallback = undefined
+ }}
+ on:confirmed={() => {
+ if (deleteConfirmedCallback) {
+ deleteConfirmedCallback()
+ }
+ deleteConfirmedCallback = undefined
+ }}
+>
+
+
Are you sure you want to remove {deletePath} ?
+ {#if deleteIsLinked}
+
+ This resource is linked with a variable of the same path. That variable is deleted with it,
+ unless another resource still references it.
+
+ {/if}
+
+
+ You can press
+ SHIFT
+ while removing a resource to bypass confirmation.
+
+
+
+
+
+
+
+
+ inferrer?.toggleDrawer?.()}>
+
+ {#snippet actions()}
+ Infer
+ {/snippet}
+
+
+
+
+
+ {#snippet titleExtra()}
+
+ {/snippet}
+
+ {#if resourceTypeViewerObj.description}
+
+ {/if}
+ {#if resourceTypeViewerObj.isFileset}
+
+ This resource type represents a collection of files. Each file is identified by its
+ relative path and contains text content. In the CLI, filesets are stored as directories.
+
+ {:else if resourceTypeViewerObj.formatExtension}
+
+ This resource type represents a plain text file with a .{resourceTypeViewerObj.formatExtension}
+ extension. (e.g. my_file.{resourceTypeViewerObj.formatExtension} )
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+ {#snippet actions()}
+ Update
+ {/snippet}
+
+
+
+
+ Description
+
+
+ {#if editResourceType.isFileset}
+
+ This resource type represents a collection of files. The schema cannot be edited.
+
+ {:else if editResourceType.formatExtension}
+
+ This resource type represents a plain text file with a .{editResourceType.formatExtension}
+ extension. (e.g. my_file.{editResourceType.formatExtension} ). The schema only
+ contains a `content` field and thus cannot be edited.
+
+ {:else}
+
Schema
+
+
+
+ {/if}
+
+
+
+
+
+
+
+ {#snippet actions()}
+ Save
+ {/snippet}
+
+
+ Name
+ Resource types are synchronized with the official types on the hub regularly. The `c_`
+ prefix is to avoid name clashes with them.
+
+
+
+ {#if !disableCustomPrefix}
+
+ c_
+
+ {/if}
+
+
+
+
+
+ {#if operatingUser.current?.is_admin || operatingUser.current?.is_super_admin}
+
+ {/if}
+
+ {#if newResourceType.name}
+ {#if !isNewResourceTypeNameValid}
+ Name must be snake_case!
+ Fix...
+ {:else if resourceTypeNameExists}
+ A resource type with this name already exists!
+ {/if}
+ {/if}
+
+
+ Description
+
+
+
+
Schema
+
+
+ Infer schema from a json value
+
+
+
+
+
+
+
+
+
+
+
+
+{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.resources}
+
+
Unauthorized
+
Page not available for operators
+
+{:else}
+
+
+ {#if showCreateButtons}
+
+
+ Add resource type
+
+ appConnect?.open?.()}
+ aiId="resources-add-resource"
+ aiDescription="Add resource"
+ >
+ Add resource
+
+
+ {/if}
+
+ (showCreateButtons = v)} />
+
+
{
+ if (e.detail == 'cache') {
+ loading.resources = true
+ loadCache()
+ } else if (e.detail == 'states') {
+ loading.resources = true
+ loadState()
+ } else if (e.detail == 'theme') {
+ loading.resources = true
+ loadTheme()
+ }
+ }}
+ >
+
+
+ {#snippet extra()}
+
+ Every resource has a Resource Type attached to it which contains its schema and make
+ it easy in scripts and flows to accept only resources of a specific resource type.
+
+ {/snippet}
+
+
+ {#snippet extra()}
+
+ States are actually resources (but excluded from the Workspace tab for clarity).
+ States are used by scripts to keep data persistent between runs of the same script by
+ the same trigger (schedule or user)
+
+ {/snippet}
+
+
+ {#snippet extra()}
+
+ Cached results are actually resources (but excluded from the Workspace tab for
+ clarity). Cache are used by flows's step to cache result to avoid recomputing
+ unnecessarily
+
+ {/snippet}
+
+
+ {#snippet extra()}
+
+ Themes are actually resources (but excluded from the Workspace tab for clarity). They
+ are CSS for the legacy low-code app editor only, and are added from the CSS panel of
+ an app rather than from this page.
+
+ {/snippet}
+
+
+
+
+ {#if tab == 'types'}
+
+ {:else}
+
+ {/if}
+
+
+
+ {#if showTable}
+
+ {#if loading.resources}
+
+ {#each new Array(6) as _}
+
+ {/each}
+ {:else if filteredItems?.length == 0}
+ {#if activeFilters}
+
+ {:else}
+
appConnect?.open?.(),
+ aiId: 'resources-empty-add-resource',
+ aiDescription: 'Add resource'
+ }
+ : undefined}
+ />
+ {/if}
+ {:else}
+
+
+
+ |
+ Path |
+ Resource type |
+ Description |
+ Status |
+ Actions |
+
+
+
+ {#if filteredItems}
+ {#each filteredItems as { path, description, resource_type, extra_perms, canWrite, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed, labels, inherited_labels, ws_specific, draft_only, is_draft }}
+ {@const hasDraft =
+ getLocalDraftHint($operatingWorkspace, 'resource', path) ?? is_draft}
+
+
+
+ |
+
+
+ |
+
+ {
+ const linkedRt = resourceTypes?.find((rt) => rt.name === resource_type)
+ if (linkedRt) {
+ resourceTypeViewerObj = {
+ rt: linkedRt.name,
+ //@ts-ignore
+ schema: linkedRt.schema,
+ description: linkedRt.description ?? '',
+ formatExtension: linkedRt.format_extension,
+ isFileset: linkedRt.is_fileset ?? false
+ }
+ resourceTypeViewer?.openDrawer?.()
+ } else {
+ sendUserToast(
+ `Resource type ${resource_type} not found in workspace.`,
+ true
+ )
+ }
+ }}
+ >
+
+
+ |
+
+
+
+ {removeMarkdown(truncate(description ?? '', 200))}
+
+
+ |
+
+
+
+ {#if is_linked}
+
+
+ {#snippet text()}
+
+ This resource is linked with a variable of the same path. They
+ are deleted and renamed together.
+
+ {/snippet}
+
+ {/if}
+
+
+ {#if is_refreshed}
+
+
+ {#snippet text()}
+
+ The OAuth token will be kept up-to-date in the background by
+ Windmill using its refresh token
+
+ {/snippet}
+
+ {/if}
+
+
+ {#if is_oauth}
+
+ {#if refresh_error}
+
+
+ {#snippet text()}
+
+ Latest exchange of the refresh token did not succeed. Error: {refresh_error}
+
+ {/snippet}
+
+ {:else if is_expired}
+
+
+
+ {#snippet text()}
+
+ 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.
+
+ {/snippet}
+
+ {:else}
+
+
+ {#snippet text()}
+
+ The resource was connected through OAuth and the token is not
+ expired.
+
+ {/snippet}
+
+ {/if}
+
+ {/if}
+
+ |
+
+
+ {#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && !operatingUser.current?.operator}
+
+ {/if}
+ {
+ shareModal?.openDrawer?.(path, 'resource')
+ }
+ },
+ {
+ displayName: 'Edit',
+ icon: Pen,
+ disabled: !canWrite || !showCreateButtons,
+ action: () => {
+ openResourceEditor(path, resource_type)
+ }
+ },
+ // The agent form covers an agent's configuration, not everything a
+ // resource carries: the workspace-specific toggle in particular is
+ // only in the generic editor. JSON rather than that editor's form,
+ // which would render the configuration field by field and write a
+ // default into every one the agent leaves unset. Both write the same
+ // draft row, so this is a second view of the same edits.
+ ...(resource_type === 'ai_agent'
+ ? [
+ {
+ displayName: 'Edit as JSON',
+ icon: Braces,
+ disabled: !canWrite || !showCreateButtons,
+ action: () => {
+ // The drawer anchors itself in the hash, which the deep-link
+ // effect would then read and route back to the agent editor.
+ // Claim it first, as the row's own link does.
+ handledHash = `#/resource/${path}`
+ // A host's item tab opens an agent on JSON already.
+ if (hosted) hosted.openItem(path)
+ else resourceEditor?.initEdit?.(path, { json: true })
+ }
+ }
+ ]
+ : []),
+ ...(!ws_specific && isDeployable('resource', path, deployUiSettings)
+ ? [
+ {
+ displayName: 'Deploy to prod/staging',
+ icon: FileUp,
+ action: () => {
+ deploymentDrawer?.openDrawer(path, 'resource')
+ }
+ }
+ ]
+ : []),
+ {
+ displayName: 'Delete',
+ disabled: !canWrite || !showCreateButtons,
+ icon: Trash,
+ type: 'delete',
+ action: (event) => {
+ // TODO
+ // @ts-ignore
+ if (event?.shiftKey) {
+ deleteResource(path, account)
+ } else {
+ deleteIsLinked = is_linked ?? false
+ deletePath = path
+ deleteConfirmedCallback = () => {
+ deleteResource(path, account)
+ }
+ }
+ }
+ },
+ ...(account != undefined
+ ? [
+ {
+ displayName: 'Refresh token',
+ icon: RotateCw,
+ action: async () => {
+ await OauthService.refreshToken({
+ workspace: $operatingWorkspace ?? '',
+ id: account ?? 0,
+ requestBody: {
+ path
+ }
+ })
+ sendUserToast('Token refreshed')
+ loadResources()
+ }
+ }
+ ]
+ : [])
+ ]}
+ />
+ |
+
+ {/each}
+ {/if}
+
+
+ {/if}
+
+ {:else if tab == 'types'}
+ {#if loading.types}
+
+ {#each new Array(6) as _}
+
+ {/each}
+ {:else if filteredResourceTypes?.length == 0}
+
+
+
+ {:else}
+
+
+
+
+ | Name |
+ Description |
+ Actions |
+
+
+
+ {#if filteredResourceTypes}
+ {#each filteredResourceTypes as { name, description, schema, canWrite, format_extension, is_fileset }}
+
+
+ {
+ resourceTypeViewerObj = {
+ rt: name,
+ //@ts-ignore
+ schema: schema,
+ description: description ?? '',
+ formatExtension: format_extension,
+ isFileset: is_fileset ?? false
+ }
+
+ resourceTypeViewer?.openDrawer?.()
+ }}
+ >
+
+
+ |
+
+
+
+
+ {removeMarkdown(truncate(description ?? '', 200))}
+
+
+ |
+
+ {#if !canWrite}
+
+
+
+ Shared globally
+
+ This resource type is from the 'admins' workspace shared with all
+ workspaces
+
+
+
+ {:else if operatingUser.current?.is_admin || operatingUser.current?.is_super_admin}
+
+ handleDeleteResourceType(name)}
+ destructive
+ >
+ Delete
+
+ startEditResourceType(name)}
+ >
+ Edit
+
+
+ {:else}
+
+ Non Editable
+
+ Since resource types are shared with the whole workspace, only admins
+ can edit/delete them
+
+
+ {/if}
+ |
+
+ {/each}
+ {/if}
+
+
+
+ {/if}
+ {/if}
+
+
+{/if}
+
+
+
+
+
+
+
{
+ loadResources()
+ }}
+/>
+
+
+
+{#if !hosted && agentEditorTarget()}
+ {#await import('$lib/components/flows/content/AgentEditorModal.svelte') then { default: AgentEditorModal }}
+ t.host === undefined}
+ onRenamed={(from, to) => {
+ void loadResources()
+ // Only while the dialog still shows the agent: closed mid-request, it already cleared the
+ // anchor, and writing it back would reopen the editor on refresh.
+ if (agentEditorTarget()?.path !== from) return
+ // Claimed first, as a row click does, so the deep-link effect does not reopen it.
+ handledHash = `#/resource/${to}`
+ setPageDrawerAnchor(RESOURCES_PATH, to)
+ }}
+ />
+ {/await}
+{/if}
diff --git a/frontend/src/lib/components/resources/resourcesFilter.ts b/frontend/src/lib/components/resources/resourcesFilter.ts
index 0526ff87b3..97d1b61c7e 100644
--- a/frontend/src/lib/components/resources/resourcesFilter.ts
+++ b/frontend/src/lib/components/resources/resourcesFilter.ts
@@ -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
}
diff --git a/frontend/src/lib/components/schedules/SchedulesList.svelte b/frontend/src/lib/components/schedules/SchedulesList.svelte
new file mode 100644
index 0000000000..80236a300f
--- /dev/null
+++ b/frontend/src/lib/components/schedules/SchedulesList.svelte
@@ -0,0 +1,718 @@
+
+
+
+
+
+{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.schedules}
+
+
Unauthorized
+
Page not available for operators
+
+{:else}
+
+
+ scheduleEditor?.openNew(false)}
+ aiId="schedules-add-schedule"
+ aiDescription="Add schedule"
+ >
+ New schedule
+
+
+
+
+
+ {#snippet children({ item })}
+
+
+
+ {/snippet}
+
+
+
+ {#if loading}
+ {#each new Array(6) as _}
+
+ {/each}
+ {:else if !schedules?.length}
+ {#if activeFilters}
+
+ {:else}
+
scheduleEditor?.openNew(false),
+ aiId: 'schedules-empty-add',
+ aiDescription: 'Add schedule'
+ }}
+ />
+ {/if}
+ {:else if items?.length}
+
+ {#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}
+
+
+
+
+
+
{
+ handledHash = `#${path}`
+ editSchedule(path, is_flow)
+ }}
+ class="min-w-0 grow hover:underline decoration-gray-400"
+ >
+
+ {summary || script_path}{hasDraft ? '*' : ''}
+
+
+ schedule: {path}
+
+
+ {#if labels?.length}
+ {#each labels as label}
+
{
+ 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}
+ {/each}
+ {/if}
+
+
+ {#if paused_until && new Date(paused_until) > new Date()}
+
+ Paused until {new Date(paused_until).toLocaleString()}
+
+ {/if}
+
+
+ {schedule}
+ {timezone}
+
+
+
+
+
+
+
+ {#if error}
+
+
+
+
+
+ {#snippet text()}
+
+ The schedule disabled itself because there was an error scheduling the
+ next job: {error}
+
+ {/snippet}
+
+ {/if}
+
+
+
+
+ {#key toggleResetVersions[path] ?? 0}
+ {
+ 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}
+
+
+
+ Runs
+
+ editSchedule(path, is_flow)}
+ unifiedSize="md"
+ startIcon={{ icon: canWrite ? Pen : Eye }}
+ variant="subtle"
+ >
+ {canWrite ? 'Edit' : 'View'}
+
+ {
+ 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')
+ }
+ }
+ ]}
+ />
+
+
+
+ {#if loadingSchedulesWithJobStats}
+
+
+ Job stats loading...
+
+ {:else}
+
+ {#if avg_s}
+
Avg: {(avg_s / 1000).toFixed(2)}s
+ {/if}
+ {#each jobs ?? [] as job}
+ {@const h = (avg_s ? job.duration_ms / avg_s : 1) * 7 + 3}
+
+
+
+
+
+ {/each}
+
+ {/if}
+
{#if edited_by}
edited by {edited_by}
{/if}
{edited_by ? 'at ' : ''}{displayDate(edited_at)}
+
+ {/each}
+
+ {:else}
+
+ {/if}
+
+ {#if items && items?.length > 15 && nbDisplayed < items.length}
+
+ {nbDisplayed} items out of {items.length}
+ (nbDisplayed += 30)}>
+ Load 30 more
+
+
+ {/if}
+
+{/if}
+
+ {
+ loadSchedules()
+ }}
+/>
diff --git a/frontend/src/lib/components/schedules/schedulesFilter.ts b/frontend/src/lib/components/schedules/schedulesFilter.ts
index 24fb1c90a3..4b887e5aa4 100644
--- a/frontend/src/lib/components/schedules/schedulesFilter.ts
+++ b/frontend/src/lib/components/schedules/schedulesFilter.ts
@@ -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
}
diff --git a/frontend/src/lib/components/sessions/PageListView.svelte b/frontend/src/lib/components/sessions/PageListView.svelte
new file mode 100644
index 0000000000..7dab54c494
--- /dev/null
+++ b/frontend/src/lib/components/sessions/PageListView.svelte
@@ -0,0 +1,131 @@
+
+
+{#snippet loading()}
+
+
+
+{/snippet}
+
+
+
+ {#key `${path}:${workspaceId}:${reloadNonce}`}
+ {#if listed?.kind === 'variable'}
+ {#await import('$lib/components/variables/VariablesList.svelte')}
+ {@render loading()}
+ {:then Module}
+
+ {/await}
+ {:else if listed?.kind === 'resource'}
+ {#await import('$lib/components/resources/ResourcesList.svelte')}
+ {@render loading()}
+ {:then Module}
+
+ {/await}
+ {:else if listed?.kind === 'schedule'}
+ {#await import('$lib/components/schedules/SchedulesList.svelte')}
+ {@render loading()}
+ {:then Module}
+
+ {/await}
+ {:else if listed?.kind === 'trigger'}
+ {#await import('$lib/components/triggers/TriggerList.svelte')}
+ {@render loading()}
+ {:then Module}
+
+ {/await}
+ {/if}
+ {/key}
+
diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte
index b88907bf02..f1d630caca 100644
--- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte
+++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte
@@ -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}
+{:else if slot.kind === 'pagelist' && mounted && runtime}
+
+ {#if overlayHostEl}
+ {#await import('./PageListView.svelte')}
+ {@render editorLoading()}
+ {:then Module}
+
+ {/await}
+ {/if}
+
{:else if slot.kind === 'artifact' && mounted}
"` 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 —
diff --git a/frontend/src/lib/components/sessions/previewPaths.ts b/frontend/src/lib/components/sessions/previewPaths.ts
index 13d5abca33..58eca9504d 100644
--- a/frontend/src/lib/components/sessions/previewPaths.ts
+++ b/frontend/src/lib/components/sessions/previewPaths.ts
@@ -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)
diff --git a/frontend/src/lib/components/sessions/previewRouter.test.ts b/frontend/src/lib/components/sessions/previewRouter.test.ts
index 3bc76882e5..143ce42975 100644
--- a/frontend/src/lib/components/sessions/previewRouter.test.ts
+++ b/frontend/src/lib/components/sessions/previewRouter.test.ts
@@ -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',
diff --git a/frontend/src/lib/components/sessions/previewRouter.ts b/frontend/src/lib/components/sessions/previewRouter.ts
index 13e7626e70..de21660f3f 100644
--- a/frontend/src/lib/components/sessions/previewRouter.ts
+++ b/frontend/src/lib/components/sessions/previewRouter.ts
@@ -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/
` 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: {
diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts
index fbb84e1431..6edc8ab1dc 100644
--- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts
+++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts
@@ -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
diff --git a/frontend/src/lib/components/triggers/TriggerList.svelte b/frontend/src/lib/components/triggers/TriggerList.svelte
new file mode 100644
index 0000000000..501eebd90f
--- /dev/null
+++ b/frontend/src/lib/components/triggers/TriggerList.svelte
@@ -0,0 +1,983 @@
+
+
+{#snippet rowTitle(row: TriggerW & { marked?: any }, hasDraft: boolean | undefined)}
+ {@const marked = ['http', 'websocket', 'kafka', 'nats', 'email'].includes(triggerKind)
+ ? row.marked
+ : undefined}
+
+ {#if marked}
+
+ {@html marked}
+
+ {: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 ? '*' : ''}
+
+ {#if ['http', 'websocket', 'kafka', 'nats', 'email'].includes(triggerKind)}
+ {row.path}
+ {:else if triggerKind === 'postgres'}
+
+ {row.postgres_resource_path}
+
+ {/if}
+
+ {#if triggerKind === 'http' && row.static_asset_config}
+ file: {row.static_asset_config.s3}
+ {:else}
+ runnable: {row.script_path}
+ {/if}
+
+{/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'}
+
+ {#if (enabled && (!pinging || row.error)) || (!enabled && row.error) || (enabled && !row.server_id)}
+
+
+
+
+
+ {#snippet text()}
+
+ {#if enabled}
+ {#if !row.server_id}
+ {status.starting}
+ {:else}
+ {status.notConnected}{row.error ? ': ' + row.error : ''}
+ {/if}
+ {:else}
+ {status.disabled}: {row.error}
+ {/if}
+
+ {/snippet}
+
+ {:else if enabled}
+
+
+
+
+ {#snippet text()}
+
+ {status.connected}{status.shuttingDown && !row.server_id ? ' (shutting down...)' : ''}
+
+ {/snippet}
+
+ {/if}
+
+ {/if}
+{/snippet}
+
+ {
+ isDeleting = false
+ pendingDelete = undefined
+ }}
+ on:confirmed={confirmPendingDelete}
+>
+
+ Are you sure you want to remove this trigger?
+ {#if pendingDelete && triggerKind === 'postgres'}
+
+
+ {:else if pendingDelete && triggerKind === 'gcp'}
+
+ {:else if pendingDelete?.subscription_name && triggerKind === 'azure'}
+
+ {/if}
+
+
+
+
+{#await EDITORS[triggerKind]() then Editor}
+
+{/await}
+{#if triggerKind === 'http'}
+ {#await import('./http/RoutesGenerator.svelte') then Generator}
+
+ {/await}
+ {#await import('./http/OpenAPISpecGenerator.svelte') then Generator}
+
+ {/await}
+{/if}
+
+ (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
+/>
+
+{#if operatorBlocked}
+
+
Unauthorized
+
Page not available for operators
+
+{:else}
+
+
+
+ {#if triggerKind === 'http'}
+ routesGenerator?.openDrawer()}
+ >
+ From OpenAPI spec
+
+ openAPISpecGenerator?.openDrawer()}
+ >
+ To OpenAPI spec
+
+ {/if}
+ {#if canCreate}
+ editor?.openNew(false)}
+ >
+ New {config.newLabel}
+
+ {/if}
+
+
+
+ {#if config.cloudDisabled && isCloudHosted()}
+
+ {config.title} are disabled in the multi-tenant cloud.
+
+
+ {/if}
+
+
+
+
+
Filter by path of
+
+ {#snippet children({ item })}
+
+
+ {/snippet}
+
+
+
+
+
+ {#if operatingUser.current?.is_super_admin && operatingUser.current.username.includes('@')}
+
+ {:else if isAdmin}
+
+ {/if}
+
+
+ {#if loading}
+ {#each new Array(6) as _}
+
+ {/each}
+ {:else if !triggers?.length}
+
editor?.openNew(false),
+ aiId: config.empty.aiId,
+ aiDescription: config.empty.aiDescription
+ }
+ : undefined}
+ />
+ {:else if items?.length}
+
+ {#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)}
+
+
+
+
+
+
{
+ if (hosted) e.preventDefault()
+ openEdit(path, is_flow)
+ }}
+ class="min-w-0 grow hover:underline decoration-gray-400"
+ >
+ {@render rowTitle(row, hasDraft)}
+
+
+
+
+ {#if triggerKind !== 'http' && triggerKind !== 'azure' && labels?.length}
+ {#each labels as label}
+ {label}
+ {/each}
+ {/if}
+
+
+ {#if live}
+ {@render statusDot(row)}
+ {/if}
+
+
+
+ {#if live}
+ 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}
+
+
+
+ {#if copy}
+ copyToClipboard(copy.value)}
+ variant="subtle"
+ unifiedSize="md"
+ startIcon={{ icon: ClipboardCopy }}
+ >
+ {copy.label}
+
+ {/if}
+ openEdit(path, is_flow)}
+ unifiedSize="md"
+ startIcon={canWrite ? { icon: Pen } : { icon: Eye }}
+ variant="subtle"
+ >
+ {canWrite ? 'Edit' : 'View'}
+
+ 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)
+ }
+ ]}
+ />
+
+
+
+
+ {#if edited_by}
edited by {edited_by}
{/if}
+
{edited_by ? 'at ' : ''}{displayDate(edited_at)}
+
+
+
+ {/each}
+
+ {:else}
+
+ {/if}
+
+ {#if items && items?.length > 15 && nbDisplayed < items.length}
+ {nbDisplayed} items out of {items.length}
+ (nbDisplayed += 30)}
+ >load 30 more
+ {/if}
+
+{/if}
+
+ {
+ loadTriggers()
+ }}
+/>
diff --git a/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte
index 7e3c2c5ac1..3afcdf1a43 100644
--- a/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte
+++ b/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte
@@ -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,
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(() => {
diff --git a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte
index 697bc00ad4..12b6bf0533 100644
--- a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte
+++ b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte
@@ -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
) {
- 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
diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte
index 30f848004f..fe472cb8c4 100644
--- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte
+++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte
@@ -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,
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
diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte
index 92902aa44b..03c4a12261 100644
--- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte
+++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte
@@ -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,
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
diff --git a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte
index 2b28ae0d8d..5fc2999704 100644
--- a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte
+++ b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte
@@ -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
) {
- 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
diff --git a/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte b/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte
index 91b2ee7e9e..71b54a39e0 100644
--- a/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte
+++ b/frontend/src/lib/components/triggers/http/RoutesGenerator.svelte
@@ -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
@@ -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 {
diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte
index 3fb5668f44..ea604bc5a8 100644
--- a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte
+++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte
@@ -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,
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(() => {
diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte
index f3a42f0d49..3627bd2d4d 100644
--- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte
+++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte
@@ -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,
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(() => {
diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte
index 16997be11a..045f7155d3 100644
--- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte
+++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte
@@ -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,
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(() => {
diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte
index 23ce3fc43c..546b4d2457 100644
--- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte
+++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte
@@ -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,
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(() => {
diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte
index a6a6b5e7a8..f0eed8a9fe 100644
--- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte
+++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte
@@ -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,
fixedScriptPath_?: string
) {
- if (handOffPageDrawer(SCHEDULES_PATH, ePath)) return
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte
index 0b3f846c97..5521eca4c3 100644
--- a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte
+++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte
@@ -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,
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(() => {
diff --git a/frontend/src/lib/components/triggers/triggerListConfig.ts b/frontend/src/lib/components/triggers/triggerListConfig.ts
new file mode 100644
index 0000000000..9e5b24c8f2
--- /dev/null
+++ b/frontend/src/lib/components/triggers/triggerListConfig.ts
@@ -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 extends unknown ? keyof T : never
+type FieldOf = 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,
+ 'path' | 'script_path' | 'is_flow' | 'mode' | 'edited_by' | 'edited_at'
+ >]?: FieldOf
+}
+
+type Call = (a: A) => Promise
+
+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 " ". */
+ modeToastLabel?: string
+ /** A failed mode change, followed by ": ". */
+ modeError: (verb: 'enable' | 'disable' | 'suspend') => string
+ /** Report a delete through a toast, as "Successfully deleted : ". */
+ deleteToastLabel?: string
+ /** Report a failed delete through a toast. */
+ catchDelete: boolean
+ list: (a: { workspace: string; includeDraftOnly?: boolean }) => Promise
+ 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 = {
+ 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)
+ }
+}
diff --git a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte
index 86285c06b3..7248aa858c 100644
--- a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte
+++ b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte
@@ -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
) {
- 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(() => {
diff --git a/frontend/src/lib/components/variables/VariablesList.svelte b/frontend/src/lib/components/variables/VariablesList.svelte
new file mode 100644
index 0000000000..66f293a11d
--- /dev/null
+++ b/frontend/src/lib/components/variables/VariablesList.svelte
@@ -0,0 +1,759 @@
+
+
+
+
+{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.variables}
+
+
Unauthorized
+
Page not available for operators
+
+{:else}
+
+
+ {#if showCreateButtons}
+
+ {#if tab == 'contextual' && (operatingUser.current?.is_admin || operatingUser.current?.is_super_admin)}
+ contextualVariableEditor?.initNew()}
+ >
+ New contextual variable
+
+ {:else}
+ variableEditor?.initNew()}
+ >
+ New variable
+
+ {/if}
+
+ {/if}
+
+ (showCreateButtons = v)} />
+
+
+
+ {
+ loadVariables()
+ }}
+ />
+
+
+
+
+
+ {#snippet extra()}
+
+ Contextual variables are passed as environment variables when running a script and
+ depends on the execution context.
+
+ {/snippet}
+
+
+ {#if tab == 'workspace'}
+
+ {/if}
+
+
+ {#if tab == 'workspace'}
+
+ {#if !filteredItems}
+
+ {#each new Array(3) as _}
+
+ {/each}
+ {:else if filteredItems.length == 0}
+ {#if activeFilters}
+
+ {:else}
+
variableEditor?.initNew(),
+ aiId: 'variables-empty-add-variable',
+ aiDescription: 'Add variable'
+ }
+ : undefined}
+ />
+ {/if}
+ {:else}
+
+
+
+ |
+ Path |
+ Value |
+ Description |
+ Status |
+ Actions |
+
+
+
+ {#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}
+
+
+
+ |
+
+
+ |
+
+
+
+ {#if value}
+ {truncate(value, 20)}
+ {:else}
+ ∗∗∗∗
+ {/if}
+
+ {#if is_secret}
+
+
+ {#snippet text()}
+ This item is secret
+ {/snippet}
+
+ {/if}
+
+ |
+
+ {truncate(description ?? '', 50)}
+ |
+
+
+
+ {#if is_linked}
+
+
+ {#snippet text()}
+
+ This variable is linked with a resource of the same path. They are
+ deleted and renamed together.
+
+ {/snippet}
+
+ {/if}
+ {#if account}
+
+
+ {#snippet text()}
+
+ This OAuth token will be kept up-to-date in the background by
+ Windmill using its refresh token
+
+ {/snippet}
+
+ {/if}
+
+ {#if is_refreshed}
+
+ {#if refresh_error}
+
+
+
+
+
+
+
+ {#snippet text()}
+
+ Latest exchange of the refresh token did not succeed. Error: {refresh_error}
+
+ {/snippet}
+
+ {:else if is_expired}
+
+
+ {#snippet text()}
+
+ 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.
+
+ {/snippet}
+
+ {:else}
+
+
+ {#snippet text()}
+
+ The variable was connected through OAuth and the token is not
+ expired.
+
+ {/snippet}
+
+ {/if}
+
+ {/if}
+
+ |
+
+ {
+ 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()
+ }
+ }
+ ]
+ : [])
+ ]
+ }}
+ />
+ |
+
+ {/each}
+
+
+ {/if}
+
+ {:else if tab == 'contextual'}
+
+ {#if loading.contextual}
+
+ {#each new Array(8) as _}
+
+ {/each}
+ {:else}
+
+ {#if contextualVariables.filter((x) => x.is_custom).length === 0}
+
contextualVariableEditor?.initNew(),
+ aiId: 'variables-empty-add-contextual-variable',
+ aiDescription: 'Add contextual variable'
+ }
+ : undefined}
+ />
+ {:else}
+ 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}
+
+ !x.is_custom)}
+ keys={['name', 'value', 'description']}
+ />
+ {/if}
+
+ {/if}
+
+
+{/if}
+
+ {
+ deleteConfirmedCallback = undefined
+ }}
+ on:confirmed={() => {
+ if (deleteConfirmedCallback) {
+ deleteConfirmedCallback()
+ }
+ deleteConfirmedCallback = undefined
+ }}
+>
+
+
Are you sure you want to remove this variable?
+ {#if deleteIsLinked}
+
+ This variable is linked with a resource of the same path. The linked resource will also be
+ deleted.
+
+ {/if}
+
+
+ You can press
+ SHIFT
+ while removing a variable to bypass confirmation.
+
+
+
+
diff --git a/frontend/src/lib/components/variables/variablesFilter.ts b/frontend/src/lib/components/variables/variablesFilter.ts
index 5f7ce1e51a..d609182da0 100644
--- a/frontend/src/lib/components/variables/variablesFilter.ts
+++ b/frontend/src/lib/components/variables/variablesFilter.ts
@@ -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
}
diff --git a/frontend/src/lib/svelte5UtilsKit.svelte.ts b/frontend/src/lib/svelte5UtilsKit.svelte.ts
index 7b6420ab7e..d77b96df06 100644
--- a/frontend/src/lib/svelte5UtilsKit.svelte.ts
+++ b/frontend/src/lib/svelte5UtilsKit.svelte.ts
@@ -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.ZodObject
@@ -58,12 +60,14 @@ function deserializeParam(raw: string, fieldSchema: z.ZodType): unknown {
export function useSearchParams(schema: S): SearchParamsResult {
const shape: Record = (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 = $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(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(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(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}`
diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte
index bfae9bb5d2..e9d4493e66 100644
--- a/frontend/src/routes/(root)/(logged)/+layout.svelte
+++ b/frontend/src/routes/(root)/(logged)/+layout.svelte
@@ -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()
diff --git a/frontend/src/routes/(root)/(logged)/amqp_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/amqp_triggers/+page.svelte
index 360defa995..8a21a3a61f 100644
--- a/frontend/src/routes/(root)/(logged)/amqp_triggers/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/amqp_triggers/+page.svelte
@@ -1,570 +1,5 @@
-
-
-
- (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
-/>
-
-
-
- amqpTriggerEditor?.openNew(false)}
- >
- New AMQP trigger
-
-
-
- {#if isCloudHosted()}
-
- AMQP triggers are disabled in the multi-tenant cloud.
-
-
- {/if}
-
-
-
-
-
Filter by path of
-
- {#snippet children({ item })}
-
-
- {/snippet}
-
-
-
-
-
- {#if $userStore?.is_super_admin && $userStore.username.includes('@')}
-
- {:else if $userStore?.is_admin || $userStore?.is_super_admin}
-
- {/if}
-
-
- {#if loading}
- {#each new Array(6) as _}
-
- {/each}
- {:else if !triggers?.length}
-
amqpTriggerEditor?.openNew(false),
- aiId: 'amqp-triggers-empty-add',
- aiDescription: 'Add AMQP trigger'
- }}
- />
- {:else if items?.length}
-
- {#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'}
-
-
-
-
-
-
amqpTriggerEditor?.openEdit(path, is_flow)}
- class="min-w-0 grow hover:underline decoration-gray-400"
- >
-
- {path}{hasDraft ? '*' : ''}
-
-
- runnable: {script_path}
-
-
-
-
-
- {#if labels?.length}
- {#each labels as label}
- {label}
- {/each}
- {/if}
-
-
-
- {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
-
-
-
-
-
- {#snippet text()}
-
- {#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}
-
- {/snippet}
-
- {:else if enabled}
-
-
-
-
- {#snippet text()}
-
- AMQP trigger is connected{!server_id ? ' (shutting down...)' : ''}
-
- {/snippet}
-
- {/if}
-
-
-
-
- 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
- />
-
-
-
- amqpTriggerEditor?.openEdit(path, is_flow)}
- unifiedSize="md"
- startIcon={canWrite
- ? { icon: Pen }
- : {
- icon: Eye
- }}
- variant="subtle"
- >
- {canWrite ? 'Edit' : 'View'}
-
- {
- 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()
- }
- }
- ]}
- />
-
-
-
-
{#if edited_by}
edited by {edited_by}
{/if}
{edited_by ? 'the ' : ''}{displayDate(edited_at)}
-
- {/each}
-
- {:else}
-
- {/if}
-
- {#if items && items?.length > 15 && nbDisplayed < items.length}
- {nbDisplayed} items out of {items.length}
- (nbDisplayed += 30)}>load 30 more
- {/if}
-
-
- {
- loadTriggers()
- }}
-/>
+
diff --git a/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte
index 11afa9f94b..2193288ccf 100644
--- a/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte
@@ -1,668 +1,5 @@
- {
- 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
- }}
->
-
- Are you sure you want to remove this trigger?
-
- {#if subscriptionToDelete}
-
- {/if}
-
-
-
-
-
-
- (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
-/>
-
-
-
- azureTriggerEditor?.openNew(false)}
- >
- New Azure Event Grid trigger
-
-
-
- {#if isCloudHosted()}
-
- Azure Event Grid triggers are disabled in the multi-tenant cloud.
-
- {/if}
-
-
-
-
-
Filter by path of
-
- {#snippet children({ item })}
-
-
- {/snippet}
-
-
-
-
-
- {#if $userStore?.is_super_admin && $userStore.username.includes('@')}
-
- {:else if $userStore?.is_admin || $userStore?.is_super_admin}
-
- {/if}
-
-
- {#if loading}
- {#each new Array(6) as _}
-
- {/each}
- {:else if !triggers?.length}
-
azureTriggerEditor?.openNew(false),
- aiId: 'azure-triggers-empty-add',
- aiDescription: 'Add Azure Event Grid trigger'
- }}
- />
- {:else if items?.length}
-
- {#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() ?? '')}
-
-
-
-
-
-
azureTriggerEditor?.openEdit(path, is_flow)}
- class="min-w-0 grow hover:underline decoration-gray-400"
- >
-
- {path} - {topic_label} ({azure_mode}, {subscription_name}){hasDraft ? '*' : ''}
-
-
- runnable: {script_path}
-
-
-
-
-
-
-
- {#if is_pull}
-
- {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
-
-
-
-
-
- {#snippet text()}
-
- {#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}
-
- {/snippet}
-
- {:else if enabled}
-
-
-
-
- {#snippet text()}
-
- Connected to Azure Event Grid{!server_id
- ? ' (shutting down...)'
- : ''}
- {/snippet}
-
- {/if}
-
- {/if}
-
-
-
- {#if is_pull}
- 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}
-
-
-
- {#if !is_pull}
-
- copyToClipboard(getHttpRoute('azure/w', path, true, workspace_id))}
- variant="subtle"
- size="xs"
- startIcon={{ icon: ClipboardCopy }}
- >
- Copy URL
-
- {/if}
- azureTriggerEditor?.openEdit(path, is_flow)}
- size="xs"
- startIcon={canWrite
- ? { icon: Pen }
- : {
- icon: Eye
- }}
- variant="subtle"
- >
- {canWrite ? 'Edit' : 'View'}
-
- {
- 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()
- }
- }
- }
- ]}
- />
-
-
-
-
{#if edited_by}
edited by {edited_by}
{/if}
{edited_by ? 'the ' : ''}{displayDate(edited_at)}
-
- {/each}
-
- {:else}
-
- {/if}
-
- {#if items && items?.length > 15 && nbDisplayed < items.length}
- {nbDisplayed} items out of {items.length}
- (nbDisplayed += 30)}>load 30 more
- {/if}
-
-
- {
- loadTriggers()
- }}
-/>
+
diff --git a/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte
index 87c9558dbb..8fb28777c8 100644
--- a/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte
@@ -1,553 +1,5 @@
-
-
-
- (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
-/>
-
-{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers}
-
-
Unauthorized
-
Page not available for operators
-
-{:else}
-
-
- {#if $userStore?.is_admin || $userStore?.is_super_admin}
- emailTriggerEditor?.openNew(false)}
- >
- New email trigger
-
- {/if}
-
-
-
-
-
-
Filter by path of
-
- {#snippet children({ item })}
-
-
- {/snippet}
-
-
-
-
-
- {#if $userStore?.is_super_admin && $userStore.username.includes('@')}
-
- {:else if $userStore?.is_admin || $userStore?.is_super_admin}
-
- {/if}
-
-
- {#if loading}
- {#each new Array(6) as _}
-
- {/each}
- {:else if !triggers?.length}
-
emailTriggerEditor?.openNew(false),
- aiId: 'email-triggers-empty-add',
- aiDescription: 'Add email trigger'
- }
- : undefined}
- />
- {:else if items?.length}
-
- {#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 ?? ''
- )}
-
-
-
-
-
-
emailTriggerEditor?.openEdit(path, is_flow)}
- class="min-w-0 grow hover:underline decoration-gray-400"
- >
-
- {#if marked}
-
- {@html marked}
-
- {:else}
- {emailAddress}
- {/if}{hasDraft ? '*' : ''}
-
-
- {path}
-
-
- runnable: {script_path}
-
-
-
-
-
- {#if labels?.length}
- {#each labels as label}
- {label}
- {/each}
- {/if}
-
-
-
-
- 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
- />
-
-
-
- copyToClipboard(emailAddress)}
- variant="subtle"
- unifiedSize="md"
- startIcon={{ icon: ClipboardCopy }}
- >
- Copy email address
-
- emailTriggerEditor?.openEdit(path, is_flow)}
- unifiedSize="md"
- startIcon={canWrite
- ? { icon: Pen }
- : {
- icon: Eye
- }}
- variant="subtle"
- >
- {canWrite ? 'Edit' : 'View'}
-
- {
- 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)
- }
- }
- }
- ]}
- />
-
-
-
-
- {#if edited_by}
edited by {edited_by}
{/if}
-
{edited_by ? 'at ' : ''}{displayDate(edited_at)}
-
-
-
- {/each}
-
- {:else}
-
- {/if}
-
- {#if items && items?.length > 15 && nbDisplayed < items.length}
- {nbDisplayed} items out of {items.length}
- (nbDisplayed += 30)}>load 30 more
- {/if}
-
-{/if}
-
- {
- loadTriggers()
- }}
-/>
+
diff --git a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte
index 24c7553378..3b61eb7801 100644
--- a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte
@@ -1,654 +1,5 @@
- {
- 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
- }}
->
-
- Are you sure you want to remove this trigger?
-
-
-
-
-
-
-
-
- (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
-/>
-
-
-
- gcpTriggerEditor?.openNew(false)}
- >
- New GCP Pub/Sub trigger
-
-
-
- {#if isCloudHosted()}
-
- GCP Pub/Sub triggers are disabled in the multi-tenant cloud.
-
- {/if}
-
-
-
-
-
Filter by path of
-
- {#snippet children({ item })}
-
-
- {/snippet}
-
-
-
-
-
- {#if $userStore?.is_super_admin && $userStore.username.includes('@')}
-
- {:else if $userStore?.is_admin || $userStore?.is_super_admin}
-
- {/if}
-
-
- {#if loading}
- {#each new Array(6) as _}
-
- {/each}
- {:else if !triggers?.length}
-
gcpTriggerEditor?.openNew(false),
- aiId: 'gcp-triggers-empty-add',
- aiDescription: 'Add GCP Pub/Sub trigger'
- }}
- />
- {:else if items?.length}
-
- {#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'}
-
-
-
-
-
-
gcpTriggerEditor?.openEdit(path, is_flow)}
- class="min-w-0 grow hover:underline decoration-gray-400"
- >
-
- {path} - {topic_id}{hasDraft ? '*' : ''}
-
-
- runnable: {script_path}
-
-
-
-
-
- {#if labels?.length}
- {#each labels as label}
- {label}
- {/each}
- {/if}
-
-
- {#if delivery_type !== 'push'}
-
- {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
-
-
-
-
-
- {#snippet text()}
-
- {#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}
-
- {/snippet}
-
- {:else if enabled}
-
-
-
-
- {#snippet text()}
-
- Connected to GCP Pub/Sub{!server_id ? ' (shutting down...)' : ''}
- {/snippet}
-
- {/if}
-
- {/if}
-
-
-
- {#if delivery_type !== 'push'}
- 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}
-
-
-
- {#if delivery_type === 'push'}
-
- copyToClipboard(getHttpRoute('gcp/w', path, true, workspace_id))}
- variant="subtle"
- size="xs"
- startIcon={{ icon: ClipboardCopy }}
- >
- Copy URL
-
- {/if}
- gcpTriggerEditor?.openEdit(path, is_flow)}
- size="xs"
- startIcon={canWrite
- ? { icon: Pen }
- : {
- icon: Eye
- }}
- variant="subtle"
- >
- {canWrite ? 'Edit' : 'View'}
-
- {
- 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()
- }
- }
- }
- ]}
- />
-
-
-
-
{#if edited_by}
edited by {edited_by}
{/if}
{edited_by ? 'the ' : ''}{displayDate(edited_at)}
-
- {/each}
-
- {:else}
-
- {/if}
-
- {#if items && items?.length > 15 && nbDisplayed < items.length}
- {nbDisplayed} items out of {items.length}
- (nbDisplayed += 30)}>load 30 more
- {/if}
-
-
- {
- loadTriggers()
- }}
-/>
+
diff --git a/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte
index 6ee790818a..c4d4f904f6 100644
--- a/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte
@@ -1,595 +1,5 @@
-
-
-
- (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
-/>
-
-{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers}
-
-
Unauthorized
-
Page not available for operators
-
-{:else}
-
-
- kafkaTriggerEditor?.openNew(false)}
- >
- New Kafka trigger
-
-
-
- {#if isCloudHosted()}
-
- Kafka triggers are disabled in the multi-tenant cloud.
-
-
- {/if}
-
-
-
-
-
Filter by path of
-
- {#snippet children({ item })}
-
-
- {/snippet}
-
-
-
-
-
- {#if $userStore?.is_super_admin && $userStore.username.includes('@')}
-
- {:else if $userStore?.is_admin || $userStore?.is_super_admin}
-
- {/if}
-
-
- {#if loading}
- {#each new Array(6) as _}
-
- {/each}
- {:else if !triggers?.length}
-
kafkaTriggerEditor?.openNew(false),
- aiId: 'kafka-triggers-empty-add',
- aiDescription: 'Add Kafka trigger'
- }}
- />
- {:else if items?.length}
-
- {#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'}
-
-
-
-
-
-
kafkaTriggerEditor?.openEdit(path, is_flow)}
- class="min-w-0 grow hover:underline decoration-gray-400"
- >
-
- {#if marked}
-
- {@html marked}
-
- {:else}
- {kafka_resource_path} - {topics.join(', ')}
- {/if}{hasDraft ? '*' : ''}
-
-
- {path}
-
-
- runnable: {script_path}
-
-
-
-
-
- {#if labels?.length}
- {#each labels as label}
- {label}
- {/each}
- {/if}
-
-
-
- {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
-
-
-
-
-
- {#snippet text()}
-
- {#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}
-
- {/snippet}
-
- {:else if enabled}
-
-
-
-
- {#snippet text()}
- Consumer is connected
- {/snippet}
-
- {/if}
-
-
-
-
- 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
- />
-
-
-
- kafkaTriggerEditor?.openEdit(path, is_flow)}
- unifiedSize="md"
- startIcon={canWrite
- ? { icon: Pen }
- : {
- icon: Eye
- }}
- variant="subtle"
- >
- {canWrite ? 'Edit' : 'View'}
-
- {
- 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')
- }
- }
- ]}
- />
-
-
-
-
{#if edited_by}
Edited by {edited_by}
{/if}
{edited_by ? 'the ' : ''}{displayDate(edited_at)}
-
- {/each}
-
- {:else}
-
- {/if}
-
- {#if items && items?.length > 15 && nbDisplayed < items.length}
- {nbDisplayed} items out of {items.length}
- (nbDisplayed += 30)}>load 30 more
- {/if}
-
-{/if}
-
- {
- loadTriggers()
- }}
-/>
+
diff --git a/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte
index 126dfda031..41c30157ff 100644
--- a/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte
@@ -1,570 +1,5 @@
-
-
-
- (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
-/>
-
-
-
- mqttTriggerEditor?.openNew(false)}
- >
- New MQTT trigger
-
-
-
- {#if isCloudHosted()}
-
- MQTT triggers are disabled in the multi-tenant cloud.
-
-
- {/if}
-
-
-
-
-
Filter by path of
-
- {#snippet children({ item })}
-
-
- {/snippet}
-
-
-
-
-
- {#if $userStore?.is_super_admin && $userStore.username.includes('@')}
-
- {:else if $userStore?.is_admin || $userStore?.is_super_admin}
-
- {/if}
-
-
- {#if loading}
- {#each new Array(6) as _}
-
- {/each}
- {:else if !triggers?.length}
-
mqttTriggerEditor?.openNew(false),
- aiId: 'mqtt-triggers-empty-add',
- aiDescription: 'Add MQTT trigger'
- }}
- />
- {:else if items?.length}
-
- {#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'}
-
-
-
-
-
-
mqttTriggerEditor?.openEdit(path, is_flow)}
- class="min-w-0 grow hover:underline decoration-gray-400"
- >
-
- {path}{hasDraft ? '*' : ''}
-
-
- runnable: {script_path}
-
-
-
-
-
- {#if labels?.length}
- {#each labels as label}
- {label}
- {/each}
- {/if}
-
-
-
- {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
-
-
-
-
-
- {#snippet text()}
-
- {#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}
-
- {/snippet}
-
- {:else if enabled}
-
-
-
-
- {#snippet text()}
-
- MQTT trigger is connected{!server_id ? ' (shutting down...)' : ''}
-
- {/snippet}
-
- {/if}
-
-
-
-
- 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
- />
-
-
-
- mqttTriggerEditor?.openEdit(path, is_flow)}
- unifiedSize="md"
- startIcon={canWrite
- ? { icon: Pen }
- : {
- icon: Eye
- }}
- variant="subtle"
- >
- {canWrite ? 'Edit' : 'View'}
-
- {
- 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()
- }
- }
- ]}
- />
-
-
-
-
{#if edited_by}
edited by {edited_by}
{/if}
{edited_by ? 'the ' : ''}{displayDate(edited_at)}
-
- {/each}
-
- {:else}
-
- {/if}
-
- {#if items && items?.length > 15 && nbDisplayed < items.length}
- {nbDisplayed} items out of {items.length}
- (nbDisplayed += 30)}>load 30 more
- {/if}
-
-
- {
- loadTriggers()
- }}
-/>
+
diff --git a/frontend/src/routes/(root)/(logged)/nats_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/nats_triggers/+page.svelte
index 834869891a..ae96a52cfc 100644
--- a/frontend/src/routes/(root)/(logged)/nats_triggers/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/nats_triggers/+page.svelte
@@ -1,592 +1,5 @@
-
-
-
- (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
-/>
-
-{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers}
-
-
Unauthorized
-
Page not available for operators
-
-{:else}
-
-
- natsTriggerEditor?.openNew(false)}
- >
- New NATS trigger
-
-
-
- {#if isCloudHosted()}
-
- NATS triggers are disabled in the multi-tenant cloud.
-
-
- {/if}
-
-
-
-
-
Filter by path of
-
- {#snippet children({ item })}
-
-
- {/snippet}
-
-
-
-
-
- {#if $userStore?.is_super_admin && $userStore.username.includes('@')}
-
- {:else if $userStore?.is_admin || $userStore?.is_super_admin}
-
- {/if}
-
-
- {#if loading}
- {#each new Array(6) as _}
-
- {/each}
- {:else if !triggers?.length}
-
natsTriggerEditor?.openNew(false),
- aiId: 'nats-triggers-empty-add',
- aiDescription: 'Add NATS trigger'
- }}
- />
- {:else if items?.length}
-
- {#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'}
-
-
-
-
-
-
natsTriggerEditor?.openEdit(path, is_flow)}
- class="min-w-0 grow hover:underline decoration-gray-400"
- >
-
- {#if marked}
-
- {@html marked}
-
- {:else}
- {nats_resource_path} - {subjects.join(', ')}
- {/if}{hasDraft ? '*' : ''}
-
-
- {path}
-
-
- runnable: {script_path}
-
-
-
-
-
- {#if labels?.length}
- {#each labels as label}
- {label}
- {/each}
- {/if}
-
-
-
- {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
-
-
-
-
-
- {#snippet text()}
-
- {#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}
-
- {/snippet}
-
- {:else if enabled}
-
-
-
-
- {#snippet text()}
- Consumer is connected
- {/snippet}
-
- {/if}
-
-
-
-
- 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
- />
-
-
-
- natsTriggerEditor?.openEdit(path, is_flow)}
- unifiedSize="md"
- startIcon={canWrite
- ? { icon: Pen }
- : {
- icon: Eye
- }}
- variant="subtle"
- >
- {canWrite ? 'Edit' : 'View'}
-
- {
- 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()
- }
- }
- ]}
- />
-
-
-
-
{#if edited_by}
edited by {edited_by}
{/if}
{edited_by ? 'the ' : ''}{displayDate(edited_at)}
-
- {/each}
-
- {:else}
-
- {/if}
-
- {#if items && items?.length > 15 && nbDisplayed < items.length}
- {nbDisplayed} items out of {items.length}
- (nbDisplayed += 30)}>load 30 more
- {/if}
-
-{/if}
-
- {
- loadTriggers()
- }}
-/>
+
diff --git a/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte
index d1c07a7e75..a25078abe1 100644
--- a/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte
@@ -1,674 +1,5 @@
- {
- 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
- }}
->
-
- Are you sure you want to remove this trigger?
-
-
-
-
-
-
-
-
-
-
- (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
-/>
-
-
-
- postgresTriggerEditor?.openNew(false)}
- >
- New Postgres trigger
-
-
-
- {#if isCloudHosted()}
-
- Postgres triggers are disabled in the multi-tenant cloud.
-
-
- {/if}
-
-
-
-
-
Filter by path of
-
- {#snippet children({ item })}
-
-
- {/snippet}
-
-
-
-
-
- {#if $userStore?.is_super_admin && $userStore.username.includes('@')}
-
- {:else if $userStore?.is_admin || $userStore?.is_super_admin}
-
- {/if}
-
-
- {#if loading}
- {#each new Array(6) as _}
-
- {/each}
- {:else if !triggers?.length}
-
postgresTriggerEditor?.openNew(false),
- aiId: 'postgres-triggers-empty-add',
- aiDescription: 'Add Postgres trigger'
- }}
- />
- {:else if items?.length}
-
- {#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'}
-
-
-
-
-
-
postgresTriggerEditor?.openEdit(path, is_flow)}
- class="min-w-0 grow hover:underline decoration-gray-400"
- >
-
- {path}{hasDraft ? '*' : ''}
-
-
- {postgres_resource_path}
-
-
- runnable: {script_path}
-
-
-
-
-
- {#if labels?.length}
- {#each labels as label}
- {label}
- {/each}
- {/if}
-
-
-
- {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
-
-
-
-
-
- {#snippet text()}
-
- {#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}
-
- {/snippet}
-
- {:else if enabled}
-
-
-
-
- {#snippet text()}
- Connected to database{!server_id ? ' (shutting down...)' : ''}
- {/snippet}
-
- {/if}
-
-
-
-
- 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
- />
-
-
-
- postgresTriggerEditor?.openEdit(path, is_flow)}
- size="xs"
- startIcon={canWrite
- ? { icon: Pen }
- : {
- icon: Eye
- }}
- variant="subtle"
- >
- {canWrite ? 'Edit' : 'View'}
-
- {
- 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')
- }
- }
- ]}
- />
-
-
-
-
{#if edited_by}
edited by {edited_by}
{/if}
{edited_by ? 'the ' : ''}{displayDate(edited_at)}
-
- {/each}
-
- {:else}
-
- {/if}
-
- {#if items && items?.length > 15 && nbDisplayed < items.length}
- {nbDisplayed} items out of {items.length}
- (nbDisplayed += 30)}>load 30 more
- {/if}
-
-
- {
- loadTriggers()
- }}
-/>
+
diff --git a/frontend/src/routes/(root)/(logged)/resources/+page.svelte b/frontend/src/routes/(root)/(logged)/resources/+page.svelte
index bf62818170..8973bf83b4 100644
--- a/frontend/src/routes/(root)/(logged)/resources/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/resources/+page.svelte
@@ -1,1588 +1,5 @@
- {
- deleteConfirmedCallback = undefined
- }}
- on:confirmed={() => {
- if (deleteConfirmedCallback) {
- deleteConfirmedCallback()
- }
- deleteConfirmedCallback = undefined
- }}
->
-
-
Are you sure you want to remove {deletePath} ?
- {#if deleteIsLinked}
-
- This resource is linked with a variable of the same path. That variable is deleted with it,
- unless another resource still references it.
-
- {/if}
-
-
- You can press
- SHIFT
- while removing a resource to bypass confirmation.
-
-
-
-
-
-
-
-
- inferrer?.toggleDrawer?.()}>
-
- {#snippet actions()}
- Infer
- {/snippet}
-
-
-
-
-
- {#snippet titleExtra()}
-
- {/snippet}
-
- {#if resourceTypeViewerObj.description}
-
- {/if}
- {#if resourceTypeViewerObj.isFileset}
-
- This resource type represents a collection of files. Each file is identified by its
- relative path and contains text content. In the CLI, filesets are stored as directories.
-
- {:else if resourceTypeViewerObj.formatExtension}
-
- This resource type represents a plain text file with a .{resourceTypeViewerObj.formatExtension}
- extension. (e.g. my_file.{resourceTypeViewerObj.formatExtension} )
-
- {:else}
-
- {/if}
-
-
-
-
-
-
- {#snippet actions()}
- Update
- {/snippet}
-
-
-
-
- Description
-
-
- {#if editResourceType.isFileset}
-
- This resource type represents a collection of files. The schema cannot be edited.
-
- {:else if editResourceType.formatExtension}
-
- This resource type represents a plain text file with a .{editResourceType.formatExtension}
- extension. (e.g. my_file.{editResourceType.formatExtension} ). The schema only
- contains a `content` field and thus cannot be edited.
-
- {:else}
-
Schema
-
-
-
- {/if}
-
-
-
-
-
-
-
- {#snippet actions()}
- Save
- {/snippet}
-
-
- Name
- Resource types are synchronized with the official types on the hub regularly. The `c_`
- prefix is to avoid name clashes with them.
-
-
-
- {#if !disableCustomPrefix}
-
- c_
-
- {/if}
-
-
-
-
-
- {#if $userStore?.is_admin || $userStore?.is_super_admin}
-
- {/if}
-
- {#if newResourceType.name}
- {#if !isNewResourceTypeNameValid}
- Name must be snake_case!
- Fix...
- {:else if resourceTypeNameExists}
- A resource type with this name already exists!
- {/if}
- {/if}
-
-
- Description
-
-
-
-
Schema
-
-
- Infer schema from a json value
-
-
-
-
-
-
-
-
-
-
-
-
-{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.resources}
-
-
Unauthorized
-
Page not available for operators
-
-{:else}
-
-
- {#if showCreateButtons}
-
-
- Add resource type
-
- appConnect?.open?.()}
- aiId="resources-add-resource"
- aiDescription="Add resource"
- >
- Add resource
-
-
- {/if}
-
- (showCreateButtons = v)} />
-
-
{
- if (e.detail == 'cache') {
- loading.resources = true
- loadCache()
- } else if (e.detail == 'states') {
- loading.resources = true
- loadState()
- } else if (e.detail == 'theme') {
- loading.resources = true
- loadTheme()
- }
- }}
- >
-
-
- {#snippet extra()}
-
- Every resource has a Resource Type attached to it which contains its schema and make
- it easy in scripts and flows to accept only resources of a specific resource type.
-
- {/snippet}
-
-
- {#snippet extra()}
-
- States are actually resources (but excluded from the Workspace tab for clarity).
- States are used by scripts to keep data persistent between runs of the same script by
- the same trigger (schedule or user)
-
- {/snippet}
-
-
- {#snippet extra()}
-
- Cached results are actually resources (but excluded from the Workspace tab for
- clarity). Cache are used by flows's step to cache result to avoid recomputing
- unnecessarily
-
- {/snippet}
-
-
- {#snippet extra()}
-
- Themes are actually resources (but excluded from the Workspace tab for clarity). They
- are CSS for the legacy low-code app editor only, and are added from the CSS panel of
- an app rather than from this page.
-
- {/snippet}
-
-
-
-
- {#if tab == 'types'}
-
- {:else}
-
- {/if}
-
-
-
- {#if showTable}
-
- {#if loading.resources}
-
- {#each new Array(6) as _}
-
- {/each}
- {:else if filteredItems?.length == 0}
- {#if activeFilters}
-
- {:else}
-
appConnect?.open?.(),
- aiId: 'resources-empty-add-resource',
- aiDescription: 'Add resource'
- }
- : undefined}
- />
- {/if}
- {:else}
-
-
-
- |
- Path |
- Resource type |
- Description |
- Status |
- Actions |
-
-
-
- {#if filteredItems}
- {#each filteredItems as { path, description, resource_type, extra_perms, canWrite, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed, labels, inherited_labels, ws_specific, draft_only, is_draft }}
- {@const hasDraft =
- getLocalDraftHint($workspaceStore, 'resource', path) ?? is_draft}
-
-
-
- |
-
-
- |
-
- {
- const linkedRt = resourceTypes?.find((rt) => rt.name === resource_type)
- if (linkedRt) {
- resourceTypeViewerObj = {
- rt: linkedRt.name,
- //@ts-ignore
- schema: linkedRt.schema,
- description: linkedRt.description ?? '',
- formatExtension: linkedRt.format_extension,
- isFileset: linkedRt.is_fileset ?? false
- }
- resourceTypeViewer?.openDrawer?.()
- } else {
- sendUserToast(
- `Resource type ${resource_type} not found in workspace.`,
- true
- )
- }
- }}
- >
-
-
- |
-
-
-
- {removeMarkdown(truncate(description ?? '', 200))}
-
-
- |
-
-
-
- {#if is_linked}
-
-
- {#snippet text()}
-
- This resource is linked with a variable of the same path. They
- are deleted and renamed together.
-
- {/snippet}
-
- {/if}
-
-
- {#if is_refreshed}
-
-
- {#snippet text()}
-
- The OAuth token will be kept up-to-date in the background by
- Windmill using its refresh token
-
- {/snippet}
-
- {/if}
-
-
- {#if is_oauth}
-
- {#if refresh_error}
-
-
- {#snippet text()}
-
- Latest exchange of the refresh token did not succeed. Error: {refresh_error}
-
- {/snippet}
-
- {:else if is_expired}
-
-
-
- {#snippet text()}
-
- 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.
-
- {/snippet}
-
- {:else}
-
-
- {#snippet text()}
-
- The resource was connected through OAuth and the token is not
- expired.
-
- {/snippet}
-
- {/if}
-
- {/if}
-
- |
-
-
- {#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && !$userStore?.operator}
-
- {/if}
- {
- shareModal?.openDrawer?.(path, 'resource')
- }
- },
- {
- displayName: 'Edit',
- icon: Pen,
- disabled: !canWrite || !showCreateButtons,
- action: () => {
- openResourceEditor(path, resource_type)
- }
- },
- // The agent form covers an agent's configuration, not everything a
- // resource carries: the workspace-specific toggle in particular is
- // only in the generic editor. JSON rather than that editor's form,
- // which would render the configuration field by field and write a
- // default into every one the agent leaves unset. Both write the same
- // draft row, so this is a second view of the same edits.
- ...(resource_type === 'ai_agent'
- ? [
- {
- displayName: 'Edit as JSON',
- icon: Braces,
- disabled: !canWrite || !showCreateButtons,
- action: () => {
- // The drawer anchors itself in the hash, which the deep-link
- // effect would then read and route back to the agent editor.
- // Claim it first, as the row's own link does.
- handledHash = `#/resource/${path}`
- resourceEditor?.initEdit?.(path, { json: true })
- }
- }
- ]
- : []),
- ...(!ws_specific && isDeployable('resource', path, deployUiSettings)
- ? [
- {
- displayName: 'Deploy to prod/staging',
- icon: FileUp,
- action: () => {
- deploymentDrawer?.openDrawer(path, 'resource')
- }
- }
- ]
- : []),
- {
- displayName: 'Delete',
- disabled: !canWrite || !showCreateButtons,
- icon: Trash,
- type: 'delete',
- action: (event) => {
- // TODO
- // @ts-ignore
- if (event?.shiftKey) {
- deleteResource(path, account)
- } else {
- deleteIsLinked = is_linked ?? false
- deletePath = path
- deleteConfirmedCallback = () => {
- deleteResource(path, account)
- }
- }
- }
- },
- ...(account != undefined
- ? [
- {
- displayName: 'Refresh token',
- icon: RotateCw,
- action: async () => {
- await OauthService.refreshToken({
- workspace: $workspaceStore ?? '',
- id: account ?? 0,
- requestBody: {
- path
- }
- })
- sendUserToast('Token refreshed')
- loadResources()
- }
- }
- ]
- : [])
- ]}
- />
- |
-
- {/each}
- {/if}
-
-
- {/if}
-
- {:else if tab == 'types'}
- {#if loading.types}
-
- {#each new Array(6) as _}
-
- {/each}
- {:else if filteredResourceTypes?.length == 0}
-
-
-
- {:else}
-
-
-
-
- | Name |
- Description |
- Actions |
-
-
-
- {#if filteredResourceTypes}
- {#each filteredResourceTypes as { name, description, schema, canWrite, format_extension, is_fileset }}
-
-
- {
- resourceTypeViewerObj = {
- rt: name,
- //@ts-ignore
- schema: schema,
- description: description ?? '',
- formatExtension: format_extension,
- isFileset: is_fileset ?? false
- }
-
- resourceTypeViewer?.openDrawer?.()
- }}
- >
-
-
- |
-
-
-
-
- {removeMarkdown(truncate(description ?? '', 200))}
-
-
- |
-
- {#if !canWrite}
-
-
-
- Shared globally
-
- This resource type is from the 'admins' workspace shared with all
- workspaces
-
-
-
- {:else if $userStore?.is_admin || $userStore?.is_super_admin}
-
- handleDeleteResourceType(name)}
- destructive
- >
- Delete
-
- startEditResourceType(name)}
- >
- Edit
-
-
- {:else}
-
- Non Editable
-
- Since resource types are shared with the whole workspace, only admins
- can edit/delete them
-
-
- {/if}
- |
-
- {/each}
- {/if}
-
-
-
- {/if}
- {/if}
-
-
-{/if}
-
-
-
-
-
-
- {
- loadResources()
- }}
-/>
-
-
-
-{#if agentEditorTarget()}
- {#await import('$lib/components/flows/content/AgentEditorModal.svelte') then { default: AgentEditorModal }}
- t.host === undefined}
- onRenamed={(from, to) => {
- void loadResources()
- // Only while the dialog still shows the agent: closed mid-request, it already cleared the
- // anchor, and writing it back would reopen the editor on refresh.
- if (agentEditorTarget()?.path !== from) return
- // Claimed first, as a row click does, so the deep-link effect does not reopen it.
- handledHash = `#/resource/${to}`
- setPageDrawerAnchor(RESOURCES_PATH, to)
- }}
- />
- {/await}
-{/if}
+
diff --git a/frontend/src/routes/(root)/(logged)/routes/+page.svelte b/frontend/src/routes/(root)/(logged)/routes/+page.svelte
index 53de5457d1..a79a1a0471 100644
--- a/frontend/src/routes/(root)/(logged)/routes/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/routes/+page.svelte
@@ -1,592 +1,5 @@
-
-
-
-
-
-
- (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
-/>
-
-{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers}
-
-
Unauthorized
-
Page not available for operators
-
-{:else}
-
-
-
- {
- routesGenerator?.openDrawer()
- }}
- >
- From OpenAPI spec
-
- {
- openAPISpecGenerator?.openDrawer()
- }}
- >
- To OpenAPI spec
-
- routeEditor?.openNew(false)}
- >
- New route
-
-
-
-
-
-
-
-
Filter by path of
-
- {#snippet children({ item })}
-
-
- {/snippet}
-
-
-
-
-
- {#if $userStore?.is_super_admin && $userStore.username.includes('@')}
-
- {:else if $userStore?.is_admin || $userStore?.is_super_admin}
-
- {/if}
-
-
- {#if loading}
- {#each new Array(6) as _}
-
- {/each}
- {:else if !triggers?.length}
-
routeEditor?.openNew(false),
- aiId: 'routes-empty-add',
- aiDescription: 'Add route'
- }}
- />
- {:else if items?.length}
-
- {#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}`}
-
-
-
-
-
-
routeEditor?.openEdit(path, is_flow)}
- class="min-w-0 grow hover:underline decoration-gray-400"
- >
-
- {#if marked}
-
- {@html marked}
-
- {:else if summary}
- {summary}
- {:else}
- {http_method.toUpperCase()}
- /{isCloudHosted() || workspaced_route || globalHttpWorkspacedRoute
- ? workspace_id + '/' + route_path
- : route_path}
- {/if}{hasDraft ? '*' : ''}
-
-
- {path}
-
-
- {#if static_asset_config}
- file: {static_asset_config.s3}
- {:else}
- runnable: {script_path}
- {/if}
-
-
-
-
-
-
-
-
-
- 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
- />
-
-
-
-
- copyToClipboard(
- getHttpRoute(
- 'r',
- route_path,
- (workspaced_route ?? false) || globalHttpWorkspacedRoute,
- workspace_id
- )
- )}
- variant="subtle"
- unifiedSize="md"
- startIcon={{ icon: ClipboardCopy }}
- >
- Copy URL
-
- routeEditor?.openEdit(path, is_flow)}
- unifiedSize="md"
- startIcon={canWrite
- ? { icon: Pen }
- : {
- icon: Eye
- }}
- variant="subtle"
- >
- {canWrite ? 'Edit' : 'View'}
-
- {
- 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)
- }
- }
- }
- ]}
- />
-
-
-
-
- {#if edited_by}
edited by {edited_by}
{/if}
-
{edited_by ? 'at ' : ''}{displayDate(edited_at)}
-
-
-
- {/each}
-
- {:else}
-
- {/if}
-
- {#if items && items?.length > 15 && nbDisplayed < items.length}
- {nbDisplayed} items out of {items.length}
- (nbDisplayed += 30)}
- >load 30 more
- {/if}
-
-{/if}
-
- {
- loadTriggers()
- }}
-/>
+
diff --git a/frontend/src/routes/(root)/(logged)/schedules/+page.svelte b/frontend/src/routes/(root)/(logged)/schedules/+page.svelte
index e403c981de..88b21bc34c 100644
--- a/frontend/src/routes/(root)/(logged)/schedules/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/schedules/+page.svelte
@@ -1,683 +1,5 @@
-
-
-
-{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.schedules}
-
-
Unauthorized
-
Page not available for operators
-
-{:else}
-
-
- scheduleEditor?.openNew(false)}
- aiId="schedules-add-schedule"
- aiDescription="Add schedule"
- >
- New schedule
-
-
-
-
-
- {#snippet children({ item })}
-
-
-
- {/snippet}
-
-
-
- {#if loading}
- {#each new Array(6) as _}
-
- {/each}
- {:else if !schedules?.length}
- {#if activeFilters}
-
- {:else}
-
scheduleEditor?.openNew(false),
- aiId: 'schedules-empty-add',
- aiDescription: 'Add schedule'
- }}
- />
- {/if}
- {:else if items?.length}
-
- {#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}
-
-
-
-
-
-
{
- handledHash = `#${path}`
- scheduleEditor?.openEdit(path, is_flow)
- }}
- class="min-w-0 grow hover:underline decoration-gray-400"
- >
-
- {summary || script_path}{hasDraft ? '*' : ''}
-
-
- schedule: {path}
-
-
- {#if labels?.length}
- {#each labels as label}
-
{
- 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}
- {/each}
- {/if}
-
-
- {#if paused_until && new Date(paused_until) > new Date()}
-
- Paused until {new Date(paused_until).toLocaleString()}
-
- {/if}
-
-
- {schedule}
- {timezone}
-
-
-
-
-
-
-
- {#if error}
-
-
-
-
-
- {#snippet text()}
-
- The schedule disabled itself because there was an error scheduling the
- next job: {error}
-
- {/snippet}
-
- {/if}
-
-
-
-
- {#key toggleResetVersions[path] ?? 0}
- {
- 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}
-
-
-
- Runs
-
- scheduleEditor?.openEdit(path, is_flow)}
- size="xs"
- startIcon={{ icon: canWrite ? Pen : Eye }}
- variant="subtle"
- >
- {canWrite ? 'Edit' : 'View'}
-
- {
- 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')
- }
- }
- ]}
- />
-
-
-
- {#if loadingSchedulesWithJobStats}
-
-
- Job stats loading...
-
- {:else}
-
- {#if avg_s}
-
Avg: {(avg_s / 1000).toFixed(2)}s
- {/if}
- {#each jobs ?? [] as job}
- {@const h = (avg_s ? job.duration_ms / avg_s : 1) * 7 + 3}
-
-
-
-
-
- {/each}
-
- {/if}
-
{#if edited_by}
edited by {edited_by}
{/if}
{edited_by ? 'the ' : ''}{displayDate(edited_at)}
-
- {/each}
-
- {:else}
-
- {/if}
-
- {#if items && items?.length > 15 && nbDisplayed < items.length}
-
- {nbDisplayed} items out of {items.length}
- (nbDisplayed += 30)}>
- Load 30 more
-
-
- {/if}
-
-{/if}
-
- {
- loadSchedules()
- }}
-/>
+
diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte
index 99fe4330cb..689afdbb9f 100644
--- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte
@@ -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
diff --git a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte
index 912d171d86..0f4155d6b3 100644
--- a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte
@@ -1,567 +1,5 @@
-
-
-
- (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
-/>
-
-
-
- sqsTriggerEditor?.openNew(false)}
- >
- New SQS trigger
-
-
-
- {#if isCloudHosted()}
-
- SQS triggers are disabled in the multi-tenant cloud.
-
-
- {/if}
-
-
-
-
-
Filter by path of
-
- {#snippet children({ item })}
-
-
- {/snippet}
-
-
-
-
-
- {#if $userStore?.is_super_admin && $userStore.username.includes('@')}
-
- {:else if $userStore?.is_admin || $userStore?.is_super_admin}
-
- {/if}
-
-
- {#if loading}
- {#each new Array(6) as _}
-
- {/each}
- {:else if !triggers?.length}
-
sqsTriggerEditor?.openNew(false),
- aiId: 'sqs-triggers-empty-add',
- aiDescription: 'Add SQS trigger'
- }}
- />
- {:else if items?.length}
-
- {#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'}
-
-
-
-
-
-
sqsTriggerEditor?.openEdit(path, is_flow)}
- class="min-w-0 grow hover:underline decoration-gray-400"
- >
-
- {path}{hasDraft ? '*' : ''}
-
-
-
- runnable: {script_path}
-
-
-
-
-
- {#if labels?.length}
- {#each labels as label}
- {label}
- {/each}
- {/if}
-
-
-
- {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
-
-
-
-
-
- {#snippet text()}
-
- {#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}
-
- {/snippet}
-
- {:else if enabled}
-
-
-
-
- {#snippet text()}
- Connected to SQS{!server_id ? ' (shutting down...)' : ''}
- {/snippet}
-
- {/if}
-
-
-
-
- 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
- />
-
-
-
- sqsTriggerEditor?.openEdit(path, is_flow)}
- size="xs"
- startIcon={canWrite
- ? { icon: Pen }
- : {
- icon: Eye
- }}
- variant="subtle"
- >
- {canWrite ? 'Edit' : 'View'}
-
- {
- 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)
- }
- }
- }
- ]}
- />
-
-
-
-
{#if edited_by}
edited by {edited_by}
{/if}
{edited_by ? 'the ' : ''}{displayDate(edited_at)}
-
- {/each}
-
- {:else}
-
- {/if}
-
- {#if items && items?.length > 15 && nbDisplayed < items.length}
- {nbDisplayed} items out of {items.length}
- (nbDisplayed += 30)}>load 30 more
- {/if}
-
-
- {
- loadTriggers()
- }}
-/>
+
diff --git a/frontend/src/routes/(root)/(logged)/variables/+page.svelte b/frontend/src/routes/(root)/(logged)/variables/+page.svelte
index 873e812987..839695361e 100644
--- a/frontend/src/routes/(root)/(logged)/variables/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/variables/+page.svelte
@@ -1,722 +1,5 @@
-
-
-{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.variables}
-
-
Unauthorized
-
Page not available for operators
-
-{:else}
-
-
- {#if showCreateButtons}
-
- {#if tab == 'contextual' && ($userStore?.is_admin || $userStore?.is_super_admin)}
- contextualVariableEditor?.initNew()}
- >
- New contextual variable
-
- {:else}
- variableEditor?.initNew()}
- >
- New variable
-
- {/if}
-
- {/if}
-
- (showCreateButtons = v)} />
-
-
-
- {
- loadVariables()
- }}
- />
-
-
-
-
-
- {#snippet extra()}
-
- Contextual variables are passed as environment variables when running a script and
- depends on the execution context.
-
- {/snippet}
-
-
- {#if tab == 'workspace'}
-
- {/if}
-
-
- {#if tab == 'workspace'}
-
- {#if !filteredItems}
-
- {#each new Array(3) as _}
-
- {/each}
- {:else if filteredItems.length == 0}
- {#if activeFilters}
-
- {:else}
-
variableEditor?.initNew(),
- aiId: 'variables-empty-add-variable',
- aiDescription: 'Add variable'
- }
- : undefined}
- />
- {/if}
- {:else}
-
-
-
- |
- Path |
- Value |
- Description |
- Status |
- Actions |
-
-
-
- {#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}
-
-
-
- |
-
-
- |
-
-
-
- {#if value}
- {truncate(value, 20)}
- {:else}
- ∗∗∗∗
- {/if}
-
- {#if is_secret}
-
-
- {#snippet text()}
- This item is secret
- {/snippet}
-
- {/if}
-
- |
-
- {truncate(description ?? '', 50)}
- |
-
-
-
- {#if is_linked}
-
-
- {#snippet text()}
-
- This variable is linked with a resource of the same path. They are
- deleted and renamed together.
-
- {/snippet}
-
- {/if}
- {#if account}
-
-
- {#snippet text()}
-
- This OAuth token will be kept up-to-date in the background by
- Windmill using its refresh token
-
- {/snippet}
-
- {/if}
-
- {#if is_refreshed}
-
- {#if refresh_error}
-
-
-
-
-
-
-
- {#snippet text()}
-
- Latest exchange of the refresh token did not succeed. Error: {refresh_error}
-
- {/snippet}
-
- {:else if is_expired}
-
-
- {#snippet text()}
-
- 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.
-
- {/snippet}
-
- {:else}
-
-
- {#snippet text()}
-
- The variable was connected through OAuth and the token is not
- expired.
-
- {/snippet}
-
- {/if}
-
- {/if}
-
- |
-
- {
- 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()
- }
- }
- ]
- : [])
- ]
- }}
- />
- |
-
- {/each}
-
-
- {/if}
-
- {:else if tab == 'contextual'}
-
- {#if loading.contextual}
-
- {#each new Array(8) as _}
-
- {/each}
- {:else}
-
- {#if contextualVariables.filter((x) => x.is_custom).length === 0}
-
contextualVariableEditor?.initNew(),
- aiId: 'variables-empty-add-contextual-variable',
- aiDescription: 'Add contextual variable'
- }
- : undefined}
- />
- {:else}
- 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}
-
- !x.is_custom)}
- keys={['name', 'value', 'description']}
- />
- {/if}
-
- {/if}
-
-
-{/if}
-
- {
- deleteConfirmedCallback = undefined
- }}
- on:confirmed={() => {
- if (deleteConfirmedCallback) {
- deleteConfirmedCallback()
- }
- deleteConfirmedCallback = undefined
- }}
->
-
-
Are you sure you want to remove this variable?
- {#if deleteIsLinked}
-
- This variable is linked with a resource of the same path. The linked resource will also be
- deleted.
-
- {/if}
-
-
- You can press
- SHIFT
- while removing a variable to bypass confirmation.
-
-
-
-
+
diff --git a/frontend/src/routes/(root)/(logged)/websocket_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/websocket_triggers/+page.svelte
index 35a167c296..176c2ccbe5 100644
--- a/frontend/src/routes/(root)/(logged)/websocket_triggers/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/websocket_triggers/+page.svelte
@@ -1,579 +1,5 @@
-
-
-
- (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
-/>
-
-
-
- websocketTriggerEditor?.openNew(false)}
- >
- New WebSocket trigger
-
-
-
- {#if isCloudHosted()}
-
- WebSocket triggers are disabled in the multi-tenant cloud.
-
-
- {/if}
-
-
-
-
-
Filter by path of
-
- {#snippet children({ item })}
-
-
- {/snippet}
-
-
-
-
-
- {#if $userStore?.is_super_admin && $userStore.username.includes('@')}
-
- {:else if $userStore?.is_admin || $userStore?.is_super_admin}
-
- {/if}
-
-
- {#if loading}
- {#each new Array(6) as _}
-
- {/each}
- {:else if !triggers?.length}
-
websocketTriggerEditor?.openNew(false),
- aiId: 'websocket-triggers-empty-add',
- aiDescription: 'Add WebSocket trigger'
- }}
- />
- {:else if items?.length}
-
- {#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'}
-
-
-
-
-
-
websocketTriggerEditor?.openEdit(path, is_flow)}
- class="min-w-0 grow hover:underline decoration-gray-400"
- >
-
- {#if marked}
-
- {@html marked}
-
- {:else}
- {url.startsWith('$script:')
- ? 'URL: ' + url.replace('$script:', 'result of script ')
- : url.startsWith('$flow:')
- ? 'URL: ' + url.replace('$flow:', 'result of flow ')
- : url}
- {/if}{hasDraft ? '*' : ''}
-
-
- {path}
-
-
- runnable: {script_path}
-
-
-
-
-
- {#if labels?.length}
- {#each labels as label}
- {label}
- {/each}
- {/if}
-
-
-
- {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
-
-
-
-
-
- {#snippet text()}
-
- {#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}
-
- {/snippet}
-
- {:else if enabled}
-
-
-
-
- {#snippet text()}
-
- WebSocket is connected{!server_id ? ' (shutting down...)' : ''}
-
- {/snippet}
-
- {/if}
-
-
-
-
- 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
- />
-
-
-
- websocketTriggerEditor?.openEdit(path, is_flow)}
- size="xs"
- startIcon={canWrite
- ? { icon: Pen }
- : {
- icon: Eye
- }}
- variant="subtle"
- >
- {canWrite ? 'Edit' : 'View'}
-
- {
- 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()
- }
- }
- ]}
- />
-
-
-
-
{#if edited_by}
edited by {edited_by}
{/if}
{edited_by ? 'the ' : ''}{displayDate(edited_at)}
-
- {/each}
-
- {:else}
-
- {/if}
-
- {#if items && items?.length > 15 && nbDisplayed < items.length}
- {nbDisplayed} items out of {items.length}
- (nbDisplayed += 30)}>load 30 more
- {/if}
-
-
- {
- loadTriggers()
- }}
-/>
+