mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-03 08:02:19 +00:00
feat: mount session list pages in process instead of iframes (#11289)
* feat: mount session list pages in process instead of iframes * fix: seed filter defaults from the query, not the cached search params * fix: type trigger list rows from the generated trigger types * fix: judge hosted lists by the operating workspace's rules and keep role-gated filters * fix: keep the user folders filter key for every role, hidden where it does not apply * fix: drop the user folders filter for users it is not offered to * fix: wait for a known user before dropping the user folders filter * style: tint trigger rows for every kind and align the schedules footer wording * fix: stamp edited_at when a schedule is updated --------- Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
co-authored by
Ruben Fiszel
parent
d02ff9ac24
commit
ebb3048ca0
+2
-2
@@ -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"
|
||||
}
|
||||
@@ -619,6 +619,7 @@ async fn edit_schedule(
|
||||
dynamic_skip = $23,
|
||||
email = $24,
|
||||
edited_by = $25,
|
||||
edited_at = now(),
|
||||
permissioned_as = $26,
|
||||
labels = COALESCE($27, labels)
|
||||
WHERE path = $19 AND workspace_id = $20
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { Button } from './common'
|
||||
import Drawer from './common/drawer/Drawer.svelte'
|
||||
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
||||
@@ -10,6 +9,9 @@
|
||||
import autosize from '$lib/autosize'
|
||||
import Label from './Label.svelte'
|
||||
import TextInput from './text_input/TextInput.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@@ -35,7 +37,7 @@
|
||||
|
||||
async function updateVariable(): Promise<void> {
|
||||
await WorkspaceService.setEnvironmentVariable({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: $operatingWorkspace!,
|
||||
requestBody: {
|
||||
value: value,
|
||||
name: name
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
collectTransformRefs
|
||||
} from './deployDependencies'
|
||||
import { base } from '$lib/base'
|
||||
import { enterpriseLicense, superadmin, workspaceStore } from '$lib/stores'
|
||||
import { enterpriseLicense, superadmin } from '$lib/stores'
|
||||
import {
|
||||
AppService,
|
||||
FlowService,
|
||||
@@ -50,6 +50,9 @@
|
||||
type OnBehalfOfDetails
|
||||
} from './OnBehalfOfSelector.svelte'
|
||||
import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@@ -209,7 +212,12 @@
|
||||
let source: string | undefined
|
||||
let targetValue: string | undefined
|
||||
try {
|
||||
source = await getOnBehalfOf(dep.kind, dep.path, $workspaceStore!, additionalInformation)
|
||||
source = await getOnBehalfOf(
|
||||
dep.kind,
|
||||
dep.path,
|
||||
$operatingWorkspace!,
|
||||
additionalInformation
|
||||
)
|
||||
} catch {}
|
||||
try {
|
||||
targetValue = await getOnBehalfOf(dep.kind, dep.path, target!, additionalInformation)
|
||||
@@ -226,7 +234,10 @@
|
||||
): Promise<{ kind: Kind; path: string }[]> {
|
||||
async function rec(kind: Kind, path: string): Promise<{ kind: Kind; path: string }[]> {
|
||||
if (kind == 'schedule') {
|
||||
const schedule = await ScheduleService.getSchedule({ workspace: $workspaceStore!, path })
|
||||
const schedule = await ScheduleService.getSchedule({
|
||||
workspace: $operatingWorkspace!,
|
||||
path
|
||||
})
|
||||
if (schedule.script_path && schedule.script_path != '') {
|
||||
if (schedule.script_path) {
|
||||
return [{ kind: 'script', path: schedule.script_path }]
|
||||
@@ -237,7 +248,7 @@
|
||||
return []
|
||||
}
|
||||
} else if (kind == 'flow') {
|
||||
const flow = await FlowService.getFlowByPath({ workspace: $workspaceStore!, path })
|
||||
const flow = await FlowService.getFlowByPath({ workspace: $operatingWorkspace!, path })
|
||||
return getAllModules(flow.value.modules, flow.value.failure_module).flatMap((x) => {
|
||||
let result: { kind: Kind; path: string }[] = []
|
||||
if (x.value.type == 'script' || x.value.type == 'rawscript' || x.value.type == 'flow') {
|
||||
@@ -257,7 +268,7 @@
|
||||
return result
|
||||
})
|
||||
} else if (kind == 'app') {
|
||||
const app = await AppService.getAppByPath({ workspace: $workspaceStore!, path })
|
||||
const app = await AppService.getAppByPath({ workspace: $operatingWorkspace!, path })
|
||||
let result: { kind: Kind; path: string }[] = []
|
||||
if (app.raw_app) {
|
||||
const rawAppValue = app.value as { runnables?: Record<string, Runnable> }
|
||||
@@ -285,7 +296,7 @@
|
||||
}
|
||||
return result
|
||||
} else if (kind == 'resource') {
|
||||
const res = await ResourceService.getResource({ workspace: $workspaceStore!, path })
|
||||
const res = await ResourceService.getResource({ workspace: $operatingWorkspace!, path })
|
||||
function recObj(obj: any): { kind: Kind; path: string }[] {
|
||||
if (typeof obj == 'string') {
|
||||
if (obj.startsWith('$var:')) {
|
||||
@@ -310,12 +321,16 @@
|
||||
]
|
||||
} else if (kind == 'trigger') {
|
||||
if (additionalInformation?.triggers) {
|
||||
return getTriggerDependency(additionalInformation.triggers.kind, path, $workspaceStore!)
|
||||
return getTriggerDependency(
|
||||
additionalInformation.triggers.kind,
|
||||
path,
|
||||
$operatingWorkspace!
|
||||
)
|
||||
}
|
||||
throw new Error('Missing trigger information')
|
||||
} else if (kind == 'script') {
|
||||
const imports = await WorkspaceService.getImports({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: $operatingWorkspace!,
|
||||
importerPath: path
|
||||
})
|
||||
return imports.map((importedPath) => ({ kind: 'script' as Kind, path: importedPath }))
|
||||
@@ -363,7 +378,7 @@
|
||||
const result = await deployItem({
|
||||
kind,
|
||||
path,
|
||||
workspaceFrom: $workspaceStore!,
|
||||
workspaceFrom: $operatingWorkspace!,
|
||||
workspaceTo: target!,
|
||||
additionalInformation,
|
||||
onBehalfOf: getOnBehalfOfForDeploy(statusPath, kind),
|
||||
@@ -398,7 +413,7 @@
|
||||
diffDrawer?.openDrawer()
|
||||
let values = await Promise.all([
|
||||
getItemValue(kind, path, workspaceToDeployTo!, additionalInformation),
|
||||
getItemValue(kind, path, $workspaceStore!, additionalInformation)
|
||||
getItemValue(kind, path, $operatingWorkspace!, additionalInformation)
|
||||
])
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
@@ -409,12 +424,12 @@
|
||||
}
|
||||
|
||||
async function loadDestinations() {
|
||||
const workspace = $workspaceStore!
|
||||
const workspace = $operatingWorkspace!
|
||||
const [deployTo, remote] = await Promise.all([
|
||||
WorkspaceService.getDeployTo({ workspace }),
|
||||
WorkspaceService.getRemoteDeployTarget({ workspace }).catch(() => undefined)
|
||||
])
|
||||
if (workspace !== $workspaceStore) return
|
||||
if (workspace !== $operatingWorkspace) return
|
||||
parentWorkspace = deployTo.deploy_to
|
||||
remoteStatus = remote
|
||||
// Keep the user's pick across a reload, but only while this workspace still has it: the
|
||||
@@ -430,7 +445,7 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
$workspaceStore && untrack(() => loadDestinations())
|
||||
$operatingWorkspace && untrack(() => loadDestinations())
|
||||
})
|
||||
|
||||
// Until the caller has a token for the remote instance there is nothing to deploy against, so
|
||||
@@ -439,7 +454,7 @@
|
||||
destination === 'parent'
|
||||
? parentWorkspace
|
||||
: isRemote && remoteStatus?.connection
|
||||
? remoteDeployWorkspace($workspaceStore!, remoteStatus.connection)
|
||||
? remoteDeployWorkspace($operatingWorkspace!, remoteStatus.connection)
|
||||
: undefined
|
||||
)
|
||||
let destinationLabel = $derived(
|
||||
@@ -560,12 +575,12 @@
|
||||
{#if isRemote && remoteTarget}
|
||||
<div class="mt-3">
|
||||
<RemoteDeployConnect
|
||||
workspace={$workspaceStore!}
|
||||
workspace={$operatingWorkspace!}
|
||||
target={remoteTarget}
|
||||
connection={remoteStatus?.connection}
|
||||
returnTo={kind === 'trigger'
|
||||
? undefined
|
||||
: `${base}/deploy/${kind}/${initialPath}?workspace=${encodeURIComponent($workspaceStore!)}`}
|
||||
: `${base}/deploy/${kind}/${initialPath}?workspace=${encodeURIComponent($operatingWorkspace!)}`}
|
||||
onChange={loadDestinations}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" module>
|
||||
import { z } from 'zod'
|
||||
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
|
||||
import { useHostedPage } from '$lib/components/hostedPage'
|
||||
import { formatDatePretty, parsePrettyDate, type IconType } from '$lib/utils'
|
||||
|
||||
export type FilterSchemaRec = Record<string, FilterSchema>
|
||||
@@ -134,8 +135,9 @@
|
||||
// URL lacks, so a value written to the instance is undone on the next sync. Going through
|
||||
// urlFilter rather than straight to history keeps the search-param cells in step, so it
|
||||
// does not matter whether a popstate follows.
|
||||
const hosted = useHostedPage()
|
||||
function seed(values: Partial<FilterInstanceRec<T>>) {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const sp = new URLSearchParams(hosted ? untrack(() => hosted.search) : window.location.search)
|
||||
for (const [key, value] of Object.entries(values) as [string, unknown][]) {
|
||||
if (value === undefined || value === null || sp.has(key)) continue
|
||||
urlFilter[key] = value instanceof Date ? value.toISOString() : value
|
||||
|
||||
@@ -4,27 +4,55 @@
|
||||
* workspace and the admin bypass in its popover. Renders nothing for operators, who have no edit
|
||||
* affordance for it to explain.
|
||||
*/
|
||||
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { resource } from 'runed'
|
||||
import {
|
||||
canUserBypassRuleKind,
|
||||
getActiveRulesetsForKind,
|
||||
isRuleActive
|
||||
canUserBypassRuleKindInRulesets,
|
||||
fetchProtectionRulesForWorkspace,
|
||||
getActiveRulesetsForKindInRulesets,
|
||||
isRuleActiveInRulesets,
|
||||
protectionRulesState
|
||||
} from '$lib/workspaceProtectionRules.svelte'
|
||||
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
|
||||
import { devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
|
||||
import { canCreateFork } from '$lib/utils/editInFork'
|
||||
import { switchWorkspace } from '$lib/storeUtils'
|
||||
import { Badge, Button } from './common'
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import { GitFork, Lock, ShieldOff } from 'lucide-svelte'
|
||||
import {
|
||||
useOperatingWorkspace,
|
||||
useOperatingUser
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
let activeDeployRulesets = $derived(getActiveRulesetsForKind('DisableDirectDeployment'))
|
||||
let canBypass = $derived(canUserBypassRuleKind('DisableDirectDeployment', $userStore))
|
||||
let canonicalDev = $derived(findCanonicalDevWorkspace($workspaceStore, $userWorkspaces))
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const operatingUser = useOperatingUser()
|
||||
|
||||
// The shared rules state holds the navigation workspace's rules, so a host acting on another
|
||||
// workspace (an AI session's fork) has to ask for that one's.
|
||||
const foreignRules = resource(
|
||||
() => ($operatingWorkspace !== $workspaceStore ? $operatingWorkspace : undefined),
|
||||
(workspace) => (workspace ? fetchProtectionRulesForWorkspace(workspace) : Promise.resolve([]))
|
||||
)
|
||||
let rulesets = $derived(
|
||||
$operatingWorkspace === $workspaceStore
|
||||
? (protectionRulesState.rulesets ?? [])
|
||||
: (foreignRules.current ?? [])
|
||||
)
|
||||
|
||||
let activeDeployRulesets = $derived(
|
||||
getActiveRulesetsForKindInRulesets(rulesets, 'DisableDirectDeployment')
|
||||
)
|
||||
let canBypass = $derived(
|
||||
canUserBypassRuleKindInRulesets(rulesets, 'DisableDirectDeployment', operatingUser.current)
|
||||
)
|
||||
let canonicalDev = $derived(findCanonicalDevWorkspace($operatingWorkspace, $userWorkspaces))
|
||||
// Forking may itself be blocked by DisableWorkspaceForking, so only suggest it
|
||||
// when the user can actually fork this workspace.
|
||||
let canFork = $derived(canCreateFork($userStore))
|
||||
let canFork = $derived(
|
||||
!isRuleActiveInRulesets(rulesets, 'DisableWorkspaceForking') ||
|
||||
canUserBypassRuleKindInRulesets(rulesets, 'DisableWorkspaceForking', operatingUser.current)
|
||||
)
|
||||
let editAdvice = $derived(
|
||||
canFork
|
||||
? 'You will need to either fork the workspace, or make your changes locally and submit a PR to an authorized user.'
|
||||
@@ -34,7 +62,9 @@
|
||||
// The toggle is only offered to a user who can bypass, but the answer can change under a
|
||||
// workspace switch, so the checked flag alone never grants the edit.
|
||||
let bypassActive = $derived(canBypass && overrideChecked)
|
||||
let canEdit = $derived(!isRuleActive('DisableDirectDeployment') || bypassActive)
|
||||
let canEdit = $derived(
|
||||
!isRuleActiveInRulesets(rulesets, 'DisableDirectDeployment') || bypassActive
|
||||
)
|
||||
|
||||
let {
|
||||
onUpdateCanEditStatus = (value) => {}
|
||||
@@ -55,7 +85,7 @@
|
||||
)
|
||||
</script>
|
||||
|
||||
{#if !$userStore?.operator && activeDeployRulesets.length > 0}
|
||||
{#if !operatingUser.current?.operator && activeDeployRulesets.length > 0}
|
||||
<div class="my-2">
|
||||
<Popover
|
||||
placement="bottom-start"
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
import OpenInSessionButton from './sessions/OpenInSessionButton.svelte'
|
||||
import {
|
||||
clearPageDrawerAnchor,
|
||||
handOffPageDrawer,
|
||||
pageDrawerSessionSource,
|
||||
setPageDrawerAnchor
|
||||
} from './sessions/pageDrawerSession'
|
||||
@@ -102,7 +101,6 @@
|
||||
* dedicated editor elsewhere: the generic form would render its configuration field by field,
|
||||
* and materialize a default into every one the value leaves out. */
|
||||
export async function initEdit(p: string, opts?: { json?: boolean }): Promise<void> {
|
||||
if (handOffPageDrawer(RESOURCES_PATH, p)) return
|
||||
// A `close({ keepAnchor })` on an already-closed drawer emits no close event, so the flag
|
||||
// would still be standing when the next drawer session ends and would swallow that one's
|
||||
// anchor clear. Every session starts having to clear its own.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
} from '$lib/gen'
|
||||
import type { Folder } from '$lib/gen/types.gen'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
|
||||
import { Alert, Button, Drawer } from './common'
|
||||
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -22,11 +22,22 @@
|
||||
import Toggle from './Toggle.svelte'
|
||||
import { Trash } from 'lucide-svelte'
|
||||
import { DEMO_RESTRICTION_HINT, isDemoWorkspaceRestricted } from '$lib/cloud'
|
||||
import {
|
||||
useOperatingWorkspace,
|
||||
useOperatingUser
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const operatingUser = useOperatingUser()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let restricted = $derived(
|
||||
isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin)
|
||||
isDemoWorkspaceRestricted(
|
||||
$operatingWorkspace,
|
||||
operatingUser.current?.is_admin,
|
||||
operatingUser.current?.is_super_admin
|
||||
)
|
||||
)
|
||||
|
||||
type Kind =
|
||||
@@ -81,7 +92,7 @@
|
||||
defaultPermsLabel = `Folder f/${folderName} permissions`
|
||||
try {
|
||||
const folder: Folder = await FolderService.getFolder({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: $operatingWorkspace!,
|
||||
name: folderName
|
||||
})
|
||||
if (path !== currentPath) return
|
||||
@@ -134,7 +145,7 @@
|
||||
const currentPath = path
|
||||
try {
|
||||
const resource = await ResourceService.getResource({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: $operatingWorkspace!,
|
||||
path: currentPath
|
||||
})
|
||||
if (path !== currentPath) return
|
||||
@@ -164,27 +175,27 @@
|
||||
}
|
||||
|
||||
async function loadOwner() {
|
||||
own = isOwner(path, $userStore!, $workspaceStore!)
|
||||
own = isOwner(path, operatingUser.current!, $operatingWorkspace!)
|
||||
}
|
||||
|
||||
async function loadAcls() {
|
||||
acls = Object.entries(
|
||||
await GranularAclService.getGranularAcls({ workspace: $workspaceStore!, path, kind })
|
||||
await GranularAclService.getGranularAcls({ workspace: $operatingWorkspace!, path, kind })
|
||||
)
|
||||
}
|
||||
|
||||
async function loadGroups(): Promise<void> {
|
||||
groups = await GroupService.listGroupNames({ workspace: $workspaceStore! })
|
||||
groups = await GroupService.listGroupNames({ workspace: $operatingWorkspace! })
|
||||
}
|
||||
|
||||
async function loadUsernames(): Promise<void> {
|
||||
usernames = await UserService.listUsernames({ workspace: $workspaceStore! })
|
||||
usernames = await UserService.listUsernames({ workspace: $operatingWorkspace! })
|
||||
}
|
||||
|
||||
async function deleteAcl(owner: string) {
|
||||
try {
|
||||
await GranularAclService.removeGranularAcls({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: $operatingWorkspace!,
|
||||
path,
|
||||
kind,
|
||||
requestBody: { owner }
|
||||
@@ -193,7 +204,7 @@
|
||||
for (const varPath of linkedVarPaths) {
|
||||
try {
|
||||
await GranularAclService.removeGranularAcls({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: $operatingWorkspace!,
|
||||
path: varPath,
|
||||
kind: 'variable',
|
||||
requestBody: { owner }
|
||||
@@ -212,7 +223,7 @@
|
||||
|
||||
async function addAcl(owner: string, write: boolean) {
|
||||
await GranularAclService.addGranularAcls({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: $operatingWorkspace!,
|
||||
path,
|
||||
kind,
|
||||
requestBody: { owner, write }
|
||||
@@ -221,7 +232,7 @@
|
||||
for (const varPath of linkedVarPaths) {
|
||||
try {
|
||||
await GranularAclService.addGranularAcls({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: $operatingWorkspace!,
|
||||
path: varPath,
|
||||
kind: 'variable',
|
||||
requestBody: { owner, write }
|
||||
@@ -251,9 +262,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-semibold text-emphasis"
|
||||
>Extra members ({acls?.length ?? 0})</span
|
||||
>
|
||||
<span class="text-sm font-semibold text-emphasis">Extra members ({acls?.length ?? 0})</span>
|
||||
{#if linkedVarPaths.length > 0}
|
||||
<div class="flex flex-col gap-1.5 p-3 border rounded bg-surface-secondary text-xs">
|
||||
<Toggle
|
||||
|
||||
@@ -12,10 +12,13 @@
|
||||
|
||||
import autosize from '$lib/autosize'
|
||||
import { ResourceService, VariableService } from '$lib/gen'
|
||||
import { oauthStore, workspaceStore } from '$lib/stores'
|
||||
import { oauthStore } from '$lib/stores'
|
||||
import Password from './Password.svelte'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import HighlightTheme from './HighlightTheme.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
let drawer: Drawer | undefined = $state()
|
||||
let token: undefined | string = $state(undefined)
|
||||
@@ -78,7 +81,7 @@
|
||||
async function save() {
|
||||
if (!path) return
|
||||
await VariableService.createVariable({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: $operatingWorkspace!,
|
||||
requestBody: {
|
||||
path,
|
||||
value: password,
|
||||
@@ -89,7 +92,7 @@
|
||||
})
|
||||
|
||||
await ResourceService.createResource({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: $operatingWorkspace!,
|
||||
requestBody: {
|
||||
resource_type: 'postgresql',
|
||||
path,
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import OpenInSessionButton from './sessions/OpenInSessionButton.svelte'
|
||||
import {
|
||||
clearPageDrawerAnchor,
|
||||
handOffPageDrawer,
|
||||
pageDrawerSessionSource,
|
||||
setPageDrawerAnchor
|
||||
} from './sessions/pageDrawerSession'
|
||||
@@ -389,7 +388,6 @@
|
||||
}
|
||||
|
||||
export function editVariable(edit_path: string): void {
|
||||
if (handOffPageDrawer(VARIABLES_PATH, edit_path)) return
|
||||
reset()
|
||||
editPath = edit_path
|
||||
selected = curWs!
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import type { BadgeColor, BadgeIconProps } from '../common/badge/model'
|
||||
import { appIconComponent } from '../icons'
|
||||
import { integrationDisplayName } from '../resourceTypeDisplay'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { useHostedPage } from '../hostedPage'
|
||||
|
||||
interface Props {
|
||||
filters: string[]
|
||||
@@ -39,42 +40,35 @@
|
||||
inline = false
|
||||
}: Props = $props()
|
||||
|
||||
const queryChange: (value: URL) => void = (url: URL) => {
|
||||
if (syncQuery) {
|
||||
window.history.pushState(history.state, '', `?${url?.searchParams.toString()}`)
|
||||
}
|
||||
}
|
||||
|
||||
const eventListener = (e: PopStateEvent) => {
|
||||
if (syncQuery) {
|
||||
loadFilterFromUrl()
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('popstate', eventListener)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('popstate', (e) => eventListener(e))
|
||||
})
|
||||
|
||||
loadFilterFromUrl()
|
||||
const hosted = useHostedPage()
|
||||
|
||||
function loadFilterFromUrl() {
|
||||
let queryValue = new URL(window.location.href).searchParams.get(queryName) ?? undefined
|
||||
selectedFilter = queryValue
|
||||
const search = hosted ? hosted.search : window.location.search
|
||||
selectedFilter = new URLSearchParams(search).get(queryName) ?? undefined
|
||||
}
|
||||
|
||||
export async function setQuery(url: URL, key: string, value: string | undefined): Promise<void> {
|
||||
if (value != undefined) {
|
||||
url.searchParams.set(key, value)
|
||||
} else {
|
||||
url.searchParams.delete(key)
|
||||
}
|
||||
queryChange(url)
|
||||
function writeQuery(value: string | undefined) {
|
||||
if (!syncQuery) return
|
||||
const params = new URLSearchParams(hosted ? hosted.search : window.location.search)
|
||||
if (value != undefined) params.set(queryName, value)
|
||||
else params.delete(queryName)
|
||||
if (hosted) hosted.setSearch(params.size ? `?${params}` : '')
|
||||
else window.history.pushState(history.state, '', `?${params}`)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!syncQuery) return
|
||||
if (hosted) {
|
||||
hosted.search
|
||||
untrack(loadFilterFromUrl)
|
||||
return
|
||||
}
|
||||
window.addEventListener('popstate', loadFilterFromUrl)
|
||||
return () => window.removeEventListener('popstate', loadFilterFromUrl)
|
||||
})
|
||||
|
||||
untrack(loadFilterFromUrl)
|
||||
|
||||
let filtersAndSelected = $derived(
|
||||
selectedFilter
|
||||
? filters.includes(selectedFilter)
|
||||
@@ -109,11 +103,7 @@
|
||||
class="inline-flex items-center gap-1 align-middle"
|
||||
onclick={() => {
|
||||
selectedFilter = selectedFilter == filter ? undefined : filter
|
||||
if (selectedFilter) {
|
||||
setQuery(new URL(window.location.href), queryName, selectedFilter)
|
||||
} else {
|
||||
setQuery(new URL(window.location.href), queryName, undefined)
|
||||
}
|
||||
writeQuery(selectedFilter)
|
||||
}}
|
||||
{color}
|
||||
clickable
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getContext, setContext } from 'svelte'
|
||||
|
||||
// A list page mounted somewhere other than its own route — an AI session's preview tab. The
|
||||
// document URL there belongs to the host, so the page's view (the filters in its query) has to
|
||||
// live wherever the host keeps it, and its rows and links open where the host decides instead of
|
||||
// in a drawer or a top-level navigation. Components under such a host read this instead of
|
||||
// `window.location`; outside one there is none, and they keep to the document.
|
||||
|
||||
export type HostedPage = {
|
||||
/** The page's query, `''` or `?…`. Reactive: the host may re-point it. */
|
||||
readonly search: string
|
||||
/** Replace the page's query. */
|
||||
setSearch(search: string): void
|
||||
/** Open one of the page's rows, by path. */
|
||||
openItem(path: string): void
|
||||
/** Follow an in-app link (base included) the page would otherwise navigate to. */
|
||||
openLink(href: string): void
|
||||
}
|
||||
|
||||
const KEY = Symbol('hostedPage')
|
||||
|
||||
/** Reads context: call during component initialisation. */
|
||||
export function setHostedPage(host: HostedPage): void {
|
||||
setContext(KEY, host)
|
||||
}
|
||||
|
||||
/** The host of this page, or undefined on the page's own route. Reads context: call during
|
||||
* component initialisation. */
|
||||
export function useHostedPage(): HostedPage | undefined {
|
||||
return getContext<HostedPage | undefined>(KEY)
|
||||
}
|
||||
@@ -15,8 +15,10 @@
|
||||
import { Badge } from '../common'
|
||||
import { forLater } from '$lib/forLater'
|
||||
import DurationMs from '../DurationMs.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
const POPUP_HEIGHT = 320 as const
|
||||
|
||||
@@ -115,7 +117,7 @@
|
||||
<span class="text-xs font-normal text-secondary">Arguments</span>
|
||||
<JobArgs
|
||||
id={job?.id}
|
||||
workspace={job?.workspace_id ?? $workspaceStore ?? 'no_w'}
|
||||
workspace={job?.workspace_id ?? $operatingWorkspace ?? 'no_w'}
|
||||
args={job?.args}
|
||||
/>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -76,15 +76,14 @@ export function buildResourcesFilterSchema({
|
||||
icon: Tag,
|
||||
description: 'Filter by label (comma-separated for multiple)'
|
||||
},
|
||||
...(showUserFoldersFilter
|
||||
? {
|
||||
user_folders_only: {
|
||||
type: 'boolean' as const,
|
||||
label: userFoldersLabel || 'User folders only',
|
||||
icon: Users,
|
||||
description: 'Show only resources in user folders'
|
||||
}
|
||||
}
|
||||
: {})
|
||||
// The key stays for every role, hidden where the filter does not apply: the list's URL sync
|
||||
// reads the key set once, before the acting user that gates it may have loaded.
|
||||
user_folders_only: {
|
||||
type: 'boolean' as const,
|
||||
label: userFoldersLabel || 'User folders only',
|
||||
icon: Users,
|
||||
description: 'Show only resources in user folders',
|
||||
hidden: !showUserFoldersFilter
|
||||
}
|
||||
} satisfies FilterSchemaRec
|
||||
}
|
||||
|
||||
@@ -0,0 +1,718 @@
|
||||
<script lang="ts">
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import {
|
||||
ScheduleService,
|
||||
type ScheduleWJobs,
|
||||
type WorkspaceDeployUISettings,
|
||||
WorkspaceService
|
||||
} from '$lib/gen'
|
||||
import { canWrite, displayDate, getLocalSetting, storeLocalSetting } from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { base } from '$app/paths'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import { Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { userWorkspaces, enterpriseLicense } from '$lib/stores'
|
||||
import {
|
||||
Calendar,
|
||||
Circle,
|
||||
Copy,
|
||||
Eye,
|
||||
FileUp,
|
||||
List,
|
||||
Loader2,
|
||||
Pen,
|
||||
Play,
|
||||
Plus,
|
||||
SearchX,
|
||||
Shield,
|
||||
Trash
|
||||
} from 'lucide-svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import FilterSearchbar, {
|
||||
hasActiveFilters,
|
||||
useUrlSyncedFilterInstance
|
||||
} from '$lib/components/FilterSearchbar.svelte'
|
||||
import { buildSchedulesFilterSchema } from '$lib/components/schedules/schedulesFilter'
|
||||
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import JobPreview from '$lib/components/jobs/JobPreview.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { page } from '$app/stores'
|
||||
import { useHostedPage } from '$lib/components/hostedPage'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
|
||||
import { runScheduleNow } from '$lib/components/triggers/scheduled/utils'
|
||||
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
|
||||
import {
|
||||
useOperatingWorkspace,
|
||||
useOperatingUser
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const operatingUser = useOperatingUser()
|
||||
const hosted = useHostedPage()
|
||||
|
||||
function editSchedule(path: string, isFlow: boolean) {
|
||||
if (hosted) hosted.openItem(path)
|
||||
else scheduleEditor?.openEdit(path, isFlow)
|
||||
}
|
||||
|
||||
type ScheduleW = ScheduleWJobs & { canWrite: boolean }
|
||||
|
||||
let schedules: ScheduleW[] = $state([])
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
let loading = $state(true)
|
||||
let loadingSchedulesWithJobStats = $state(true)
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $operatingWorkspace! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
async function loadSchedules(): Promise<void> {
|
||||
const currentFilters = filters.val
|
||||
|
||||
// Build API parameters from filters.
|
||||
// `includeDraftOnly` surfaces per-user drafts at paths that have
|
||||
// no deployed schedule — appended server-side when no narrowing
|
||||
// filter is set, so the user sees AI-agent-created drafts on
|
||||
// the home page.
|
||||
const apiParams: any = {
|
||||
workspace: $operatingWorkspace!,
|
||||
includeDraftOnly: true
|
||||
}
|
||||
|
||||
if (currentFilters.schedule_path) {
|
||||
apiParams.schedulePath = currentFilters.schedule_path
|
||||
}
|
||||
if (currentFilters.path_start) {
|
||||
apiParams.pathStart = currentFilters.path_start
|
||||
}
|
||||
if (currentFilters.path) {
|
||||
apiParams.path = currentFilters.path
|
||||
}
|
||||
if (currentFilters.description) {
|
||||
apiParams.description = currentFilters.description
|
||||
}
|
||||
if (currentFilters.summary) {
|
||||
apiParams.summary = currentFilters.summary
|
||||
}
|
||||
if (currentFilters.args) {
|
||||
apiParams.args = currentFilters.args
|
||||
}
|
||||
if (currentFilters._default_) {
|
||||
apiParams.broadFilter = currentFilters._default_
|
||||
}
|
||||
if (currentFilters.label) {
|
||||
apiParams.label = currentFilters.label
|
||||
}
|
||||
|
||||
const result = (await ScheduleService.listSchedules(apiParams)).map((x) => {
|
||||
return { canWrite: canWrite(x.path, x.extra_perms!, operatingUser.current), ...x }
|
||||
})
|
||||
|
||||
// Extract unique values for autocomplete
|
||||
allPaths = Array.from(new Set(result.map((x) => x.path))).sort()
|
||||
allScriptPaths = Array.from(new Set(result.map((x) => x.script_path))).sort()
|
||||
allLabels = Array.from(
|
||||
new Set(result.flatMap((x) => [...(x.labels ?? []), ...(x.inherited_labels ?? [])]))
|
||||
).sort()
|
||||
|
||||
schedules = result
|
||||
loading = false
|
||||
// after the schedule core data has been loaded, load all the job stats
|
||||
// TODO: we could potentially not reload the job stats on every call to loadSchedules, but for now it's
|
||||
// simpler to always call it. Update if performance becomes an issue.
|
||||
loadSchedulesWithJobStats()
|
||||
}
|
||||
|
||||
// Reload schedules when filters change
|
||||
$effect(() => {
|
||||
filters.val
|
||||
if ($operatingWorkspace) {
|
||||
untrack(() => loadSchedules())
|
||||
}
|
||||
})
|
||||
|
||||
async function loadSchedulesWithJobStats(): Promise<void> {
|
||||
loadingSchedulesWithJobStats = true
|
||||
let schedulesWithJobsByPath = new Map<string, ScheduleW>()
|
||||
let schedulesWithJobsList = await ScheduleService.listSchedulesWithJobs({
|
||||
workspace: $operatingWorkspace!
|
||||
})
|
||||
schedulesWithJobsList.map((x) => {
|
||||
schedulesWithJobsByPath[x.path] = x
|
||||
})
|
||||
for (let schedule of schedules) {
|
||||
if (schedulesWithJobsByPath[schedule.path]) {
|
||||
schedule.jobs = schedulesWithJobsByPath[schedule.path].jobs
|
||||
}
|
||||
}
|
||||
loadingSchedulesWithJobStats = false
|
||||
}
|
||||
|
||||
// Per-path counter bumped when a schedule toggle is cancelled or errors,
|
||||
// to force-remount that row's <Toggle>. Toggle uses `bind:checked` on
|
||||
// its native input; once the user clicks, the local checkbox state
|
||||
// diverges from the parent's prop expression, and Svelte 5 prop
|
||||
// reactivity won't push a same-valued prop back down. Re-mounting
|
||||
// re-initializes from the prop. List-page rows don't optimistically
|
||||
// flip `enabled`, so they need this nudge — but only the affected row,
|
||||
// not all rows on the page.
|
||||
let toggleResetVersions = $state<Record<string, number>>({})
|
||||
function bumpToggleReset(path: string) {
|
||||
toggleResetVersions[path] = (toggleResetVersions[path] ?? 0) + 1
|
||||
}
|
||||
|
||||
async function setScheduleEnabled(path: string, enabled: boolean): Promise<void> {
|
||||
try {
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
ScheduleService.setScheduleEnabled({
|
||||
path,
|
||||
workspace: $operatingWorkspace!,
|
||||
requestBody: { enabled, force }
|
||||
}),
|
||||
'schedule'
|
||||
)
|
||||
if (ok) {
|
||||
loadSchedules()
|
||||
} else {
|
||||
// Cancelled — nothing changed on the server, skip the reload
|
||||
// (which would re-fetch job stats and flash the loading flag)
|
||||
// and just nudge the toggle back to the prop value.
|
||||
bumpToggleReset(path)
|
||||
}
|
||||
} catch (err) {
|
||||
sendUserToast(`Cannot ` + (enabled ? 'enable' : 'disable') + ` schedule: ${err.body}`, true)
|
||||
bumpToggleReset(path)
|
||||
loadSchedules()
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($operatingWorkspace && operatingUser.current) {
|
||||
untrack(() => {
|
||||
loadSchedules()
|
||||
})
|
||||
}
|
||||
})
|
||||
let scheduleEditor: ScheduleEditor | undefined = $state()
|
||||
|
||||
// Deep link: #<path> opens that schedule's edit drawer. Tracks the last
|
||||
// handled hash (not a one-shot flag) so a hash change on the already-mounted
|
||||
// page (e.g. the AI session preview re-pointing its tab) opens the drawer
|
||||
// too. Row links pre-set handledHash: their onclick already opens the drawer.
|
||||
let handledHash = ''
|
||||
$effect(() => {
|
||||
// A host opens rows itself, and the document's hash is not this page's.
|
||||
if (hosted) return
|
||||
const hash = $page.url.hash
|
||||
if (hash.length <= 1) {
|
||||
// Navigating away from a drawer target must clear the tracker, or
|
||||
// re-targeting the same schedule later would be skipped as already handled.
|
||||
handledHash = ''
|
||||
return
|
||||
}
|
||||
if (hash === handledHash || schedules.length === 0 || !scheduleEditor) return
|
||||
const path = hash.slice(1)
|
||||
const schedule = schedules.find((s) => s.path === path)
|
||||
if (schedule) {
|
||||
handledHash = hash
|
||||
scheduleEditor.openEdit(path, schedule.is_flow)
|
||||
}
|
||||
})
|
||||
|
||||
// Collect unique values for filter autocomplete
|
||||
let allPaths: string[] = $state([])
|
||||
let allScriptPaths: string[] = $state([])
|
||||
let allLabels: string[] = $state([])
|
||||
|
||||
// FilterSearchbar setup
|
||||
let userFoldersFilterType = $derived(
|
||||
operatingUser.current?.is_super_admin && operatingUser.current.username.includes('@')
|
||||
? 'only f/*'
|
||||
: operatingUser.current?.is_admin || operatingUser.current?.is_super_admin
|
||||
? 'u/username and f/*'
|
||||
: undefined
|
||||
)
|
||||
let schedulesFilterSchema = $derived(
|
||||
buildSchedulesFilterSchema({
|
||||
paths: allPaths,
|
||||
scriptPaths: allScriptPaths,
|
||||
labels: allLabels,
|
||||
showUserFoldersFilter: userFoldersFilterType !== undefined,
|
||||
userFoldersLabel:
|
||||
userFoldersFilterType === 'only f/*'
|
||||
? 'Only f/*'
|
||||
: `Only u/${operatingUser.current?.username} and f/*`
|
||||
})
|
||||
)
|
||||
let filters = useUrlSyncedFilterInstance(untrack(() => schedulesFilterSchema))
|
||||
// A link can carry the user folders filter to someone it is not offered to, where it would sit
|
||||
// set with no chip to clear it. Dropped only once a user is actually known: `resolved` holds for
|
||||
// the navigation workspace even while its user is still loading.
|
||||
$effect(() => {
|
||||
if (!operatingUser.resolved($operatingWorkspace) || !operatingUser.current) return
|
||||
if (userFoldersFilterType !== undefined) return
|
||||
if (!filters.val.user_folders_only) return
|
||||
untrack(() => {
|
||||
const { user_folders_only: _, ...rest } = filters.val
|
||||
filters.val = rest
|
||||
})
|
||||
})
|
||||
|
||||
let activeFilters = $derived(hasActiveFilters(filters.val))
|
||||
let allFolders = $derived(
|
||||
Array.from(
|
||||
new Set(
|
||||
(schedules ?? [])
|
||||
.map((x) => x.path.split('/').slice(0, 2).join('/'))
|
||||
.filter((x) => x.startsWith('f/'))
|
||||
)
|
||||
)
|
||||
.sort()
|
||||
.map((f) => f.replace(/^f\//, ''))
|
||||
)
|
||||
let presets = $derived([
|
||||
...allFolders.map((f) => ({ name: `f/${f}`, value: `path_start:\\ f/${f}/` })),
|
||||
...allLabels.map((l) => ({ name: l, value: `label:\\ ${l}` })),
|
||||
...(!schedulesFilterSchema.user_folders_only.hidden
|
||||
? [
|
||||
{
|
||||
name: schedulesFilterSchema.user_folders_only.label ?? '?',
|
||||
value: 'user_folders_only:\\ true'
|
||||
}
|
||||
]
|
||||
: [])
|
||||
])
|
||||
|
||||
let nbDisplayed = $state(15)
|
||||
let filterEnabledDisabled: 'all' | 'enabled' | 'disabled' = $state('all')
|
||||
|
||||
const SCHEDULE_PATH_KIND_FILTER_SETTING = 'schedulePathKindFilter'
|
||||
let selectedFilterKind = $state(
|
||||
(getLocalSetting(SCHEDULE_PATH_KIND_FILTER_SETTING) as 'schedule' | 'script_flow') ?? 'schedule'
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
storeLocalSetting(SCHEDULE_PATH_KIND_FILTER_SETTING, selectedFilterKind)
|
||||
})
|
||||
|
||||
function filterItemsPathsBaseOnUserFilters(
|
||||
item: ScheduleW,
|
||||
selectedFilterKind: 'schedule' | 'script_flow',
|
||||
userFoldersOnly: boolean
|
||||
) {
|
||||
if (userFoldersOnly) {
|
||||
if (selectedFilterKind === 'schedule') {
|
||||
return (
|
||||
!item.path.startsWith('u/') ||
|
||||
item.path.startsWith('u/' + operatingUser.current?.username + '/')
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
!item.script_path.startsWith('u/') ||
|
||||
item.script_path.startsWith('u/' + operatingUser.current?.username + '/')
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function filterItemsBasedOnEnabledDisabled(
|
||||
item: ScheduleW,
|
||||
filterEnabledDisabled: 'all' | 'enabled' | 'disabled'
|
||||
) {
|
||||
// Draft-only rows have nothing deployed, so they read as disabled here
|
||||
// too — matching the off, disabled toggle the row renders.
|
||||
const enabled = !item.draft_only && item.enabled
|
||||
if (filterEnabledDisabled === 'all') return true
|
||||
if (filterEnabledDisabled === 'enabled') return enabled
|
||||
if (filterEnabledDisabled === 'disabled') return !enabled
|
||||
}
|
||||
|
||||
// Filter schedules client-side for enabled/disabled and user folders
|
||||
let filteredItems = $derived.by(() => {
|
||||
return schedules?.filter(
|
||||
(x) =>
|
||||
filterItemsPathsBaseOnUserFilters(
|
||||
x,
|
||||
selectedFilterKind,
|
||||
userFoldersFilterType !== undefined && !!filters.val.user_folders_only
|
||||
) && filterItemsBasedOnEnabledDisabled(x, filterEnabledDisabled)
|
||||
)
|
||||
})
|
||||
|
||||
let items = $derived(filteredItems)
|
||||
</script>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<ScheduleEditor onUpdate={loadSchedules} bind:this={scheduleEditor} />
|
||||
|
||||
{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.schedules}
|
||||
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
|
||||
<p class="font-bold">Unauthorized</p>
|
||||
<p>Page not available for operators</p>
|
||||
</div>
|
||||
{:else}
|
||||
<CenteredPage>
|
||||
<PageHeader
|
||||
title="Schedules"
|
||||
tooltip="Trigger Scripts and Flows according to a cron schedule"
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/scheduling"
|
||||
>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => scheduleEditor?.openNew(false)}
|
||||
aiId="schedules-add-schedule"
|
||||
aiDescription="Add schedule"
|
||||
>
|
||||
New schedule
|
||||
</Button>
|
||||
</PageHeader>
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<div class="flex flex-row items-center justify-end gap-4 pb-4">
|
||||
<ToggleButtonGroup bind:selected={filterEnabledDisabled} class="w-fit">
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="all" label="All" {item} />
|
||||
<ToggleButton value="enabled" label="Enabled" {item} />
|
||||
<ToggleButton value="disabled" label="Disabled" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
<FilterSearchbar
|
||||
schema={schedulesFilterSchema}
|
||||
bind:value={filters.val}
|
||||
class="grow max-w-[26rem]"
|
||||
{presets}
|
||||
/>
|
||||
</div>
|
||||
{#if loading}
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[6], 0.4]} />
|
||||
{/each}
|
||||
{:else if !schedules?.length}
|
||||
{#if activeFilters}
|
||||
<EmptyState
|
||||
icon={SearchX}
|
||||
title="No schedules found"
|
||||
description="No schedule matches the current filters. Try clearing or widening them."
|
||||
/>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={Calendar}
|
||||
title="No schedules yet"
|
||||
description="Schedules run a script or flow automatically on a cron expression."
|
||||
action={{
|
||||
label: 'Add a schedule',
|
||||
icon: Plus,
|
||||
onClick: () => scheduleEditor?.openNew(false),
|
||||
aiId: 'schedules-empty-add',
|
||||
aiDescription: 'Add schedule'
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{:else if items?.length}
|
||||
<div class="border rounded-md divide-y">
|
||||
{#each items.slice(0, nbDisplayed) as { path, error, summary, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, jobs, paused_until, labels, inherited_labels, draft_only, is_draft } (path)}
|
||||
{@const hasDraft =
|
||||
getLocalDraftHint($operatingWorkspace, 'trigger_schedule', path) ?? is_draft}
|
||||
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
|
||||
{@const avg_s = jobs
|
||||
? jobs.reduce((acc, x) => acc + x.duration_ms, 0) / jobs.length
|
||||
: undefined}
|
||||
|
||||
<div
|
||||
class="bg-surface-tertiary hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
|
||||
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
|
||||
>
|
||||
<div class="w-full flex gap-4 items-center">
|
||||
<RowIcon kind={is_flow ? 'flow' : 'script'} />
|
||||
|
||||
<a
|
||||
href="#{path}"
|
||||
onclick={() => {
|
||||
handledHash = `#${path}`
|
||||
editSchedule(path, is_flow)
|
||||
}}
|
||||
class="min-w-0 grow hover:underline decoration-gray-400"
|
||||
>
|
||||
<div
|
||||
class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate"
|
||||
>
|
||||
{summary || script_path}{hasDraft ? '*' : ''}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left">
|
||||
schedule: {path}
|
||||
</div>
|
||||
</a>
|
||||
{#if labels?.length}
|
||||
{#each labels as label}
|
||||
<Badge
|
||||
color="blue"
|
||||
small
|
||||
class="px-1"
|
||||
title="Label: {label}"
|
||||
clickable
|
||||
onclick={() => {
|
||||
const arr = (filters.val.label ?? '').split(',').filter(Boolean)
|
||||
const idx = arr.indexOf(label)
|
||||
if (idx >= 0) arr.splice(idx, 1)
|
||||
else arr.push(label)
|
||||
const newFilters = { ...filters.val }
|
||||
if (arr.length) newFilters.label = arr.join(',')
|
||||
else delete newFilters.label
|
||||
filters.val = newFilters
|
||||
}}>{label}</Badge
|
||||
>
|
||||
{/each}
|
||||
{/if}
|
||||
<InheritedLabels labels={inherited_labels} />
|
||||
|
||||
{#if paused_until && new Date(paused_until) > new Date()}
|
||||
<div class="pb-1">
|
||||
<Badge color="yellow"
|
||||
>Paused until {new Date(paused_until).toLocaleString()}</Badge
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="gap-2 items-center hidden md:flex">
|
||||
<Badge large color="blue">{schedule}</Badge>
|
||||
<Badge small color="gray">{timezone}</Badge>
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:flex flex-row gap-1 items-center">
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
</div>
|
||||
|
||||
<div class="w-10">
|
||||
{#if error}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle
|
||||
class="text-red-600 animate-ping absolute inline-flex fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
The schedule disabled itself because there was an error scheduling the
|
||||
next job: {error}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
|
||||
<DraftBadge {draft_only} is_draft={hasDraft} />
|
||||
{#key toggleResetVersions[path] ?? 0}
|
||||
<Toggle
|
||||
disabled={draft_only}
|
||||
options={{
|
||||
title: draft_only
|
||||
? 'Draft only: deploy the schedule to enable it'
|
||||
: hasDraft
|
||||
? 'Enables/disables the deployed schedule; the draft is not affected'
|
||||
: undefined
|
||||
}}
|
||||
checked={!draft_only && enabled}
|
||||
on:change={(e) => {
|
||||
if (canWrite) {
|
||||
setScheduleEnabled(path, e.detail)
|
||||
} else {
|
||||
sendUserToast('not enough permission', true)
|
||||
// Permission denied — bump the row's reset
|
||||
// counter so the Toggle remounts back to the
|
||||
// prop value. Without this, the local
|
||||
// `bind:checked` flip from the user's click
|
||||
// stays stuck on.
|
||||
bumpToggleReset(path)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
<div class="flex gap-2 items-center justify-end">
|
||||
<Button
|
||||
href={`${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true`}
|
||||
disabled={draft_only}
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: List }}
|
||||
variant="subtle"
|
||||
>
|
||||
Runs
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => editSchedule(path, is_flow)}
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: canWrite ? Pen : Eye }}
|
||||
variant="subtle"
|
||||
>
|
||||
{canWrite ? 'Edit' : 'View'}
|
||||
</Button>
|
||||
<Dropdown
|
||||
size="md"
|
||||
items={[
|
||||
{
|
||||
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
|
||||
icon: Eye,
|
||||
action: () => {
|
||||
if (hosted) hosted.openLink(`${base}${href}`)
|
||||
else goto(href)
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: `Duplicate schedule`,
|
||||
icon: Copy,
|
||||
action: () => {
|
||||
scheduleEditor?.openNew(is_flow, script_path, path)
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
type: 'delete',
|
||||
icon: Trash,
|
||||
disabled: !canWrite,
|
||||
action: async () => {
|
||||
await ScheduleService.deleteSchedule({
|
||||
workspace: $operatingWorkspace ?? '',
|
||||
path
|
||||
})
|
||||
loadSchedules()
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: canWrite ? 'Edit' : 'View',
|
||||
icon: canWrite ? Pen : Eye,
|
||||
action: () => {
|
||||
editSchedule(path, is_flow)
|
||||
}
|
||||
},
|
||||
...(isDeployable('trigger', path, deployUiSettings)
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer?.openDrawer(path, 'trigger', {
|
||||
triggers: {
|
||||
kind: 'schedules'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'View runs',
|
||||
icon: List,
|
||||
href: `${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true`
|
||||
},
|
||||
{
|
||||
displayName: 'Audit logs',
|
||||
icon: Eye,
|
||||
href: `${base}/audit_logs?resource=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Run now',
|
||||
icon: Play,
|
||||
action: () => {
|
||||
runScheduleNow(script_path, path, is_flow, $operatingWorkspace!)
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
action: () => {
|
||||
shareModal?.openDrawer(path, 'schedule')
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-between items-baseline">
|
||||
{#if loadingSchedulesWithJobStats}
|
||||
<div class="flex gap-1 ml-0.5 text-xs text-secondary items-center">
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
<span>Job stats loading...</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex gap-1.5 ml-0.5 items-baseline flex-row-reverse">
|
||||
{#if avg_s}
|
||||
<div class="pl-2 text-secondary text-xs"
|
||||
>Avg: {(avg_s / 1000).toFixed(2)}s</div
|
||||
>
|
||||
{/if}
|
||||
{#each jobs ?? [] as job}
|
||||
{@const h = (avg_s ? job.duration_ms / avg_s : 1) * 7 + 3}
|
||||
<a href="{base}/run/{job.id}?workspace={$operatingWorkspace}">
|
||||
<JobPreview id={job.id} class="p-4">
|
||||
<div>
|
||||
<div
|
||||
class="{job.success ? 'bg-green-300' : 'bg-red-300'} mx-auto w-1.5"
|
||||
style="height: {h}px"
|
||||
></div>
|
||||
<!-- <div class="text-[0.6em] mt-0.5 text-center text-primary"
|
||||
>{(job.duration_ms / 1000).toFixed(2)}s</div
|
||||
> -->
|
||||
</div>
|
||||
</JobPreview>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="flex flex-wrap text-xs text-secondary gap-1 items-center justify-end truncate pr-2"
|
||||
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
|
||||
class="truncate">{edited_by ? 'at ' : ''}{displayDate(edited_at)}</div
|
||||
></div
|
||||
></div
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<NoItemFound />
|
||||
{/if}
|
||||
</div>
|
||||
{#if items && items?.length > 15 && nbDisplayed < items.length}
|
||||
<div class="flex items-center gap-4 text-xs font-semibold text-emphasis">
|
||||
<span>{nbDisplayed} items out of {items.length}</span>
|
||||
<Button unifiedSize="sm" variant="subtle" on:click={() => (nbDisplayed += 30)}>
|
||||
Load 30 more
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</CenteredPage>
|
||||
{/if}
|
||||
|
||||
<ShareModal
|
||||
bind:this={shareModal}
|
||||
on:change={() => {
|
||||
loadSchedules()
|
||||
}}
|
||||
/>
|
||||
@@ -73,15 +73,14 @@ export function buildSchedulesFilterSchema({
|
||||
icon: Tag,
|
||||
description: 'Filter by label (comma-separated for multiple)'
|
||||
},
|
||||
...(showUserFoldersFilter
|
||||
? {
|
||||
user_folders_only: {
|
||||
type: 'boolean' as const,
|
||||
label: userFoldersLabel || 'User folders only',
|
||||
icon: Users,
|
||||
description: 'Show only schedules in user folders'
|
||||
}
|
||||
}
|
||||
: {})
|
||||
// The key stays for every role, hidden where the filter does not apply: the list's URL sync
|
||||
// reads the key set once, before the acting user that gates it may have loaded.
|
||||
user_folders_only: {
|
||||
type: 'boolean' as const,
|
||||
label: userFoldersLabel || 'User folders only',
|
||||
icon: Users,
|
||||
description: 'Show only schedules in user folders',
|
||||
hidden: !showUserFoldersFilter
|
||||
}
|
||||
} satisfies FilterSchemaRec
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<script lang="ts">
|
||||
import { setContext, untrack } from 'svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { setOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { setHostedPage, type HostedPage } from '$lib/components/hostedPage'
|
||||
import { pageItemForListPath, parsePreviewItemRoute } from './previewPaths'
|
||||
import { previewLocationLabel } from './previewRouter'
|
||||
import { whereIs } from './sessionPreviewTabs.svelte'
|
||||
import type { SessionPreviewTab } from './sessionState.svelte'
|
||||
import type { SessionRuntime } from './sessionRuntime.svelte'
|
||||
|
||||
let {
|
||||
runtime,
|
||||
tab,
|
||||
path,
|
||||
workspaceId,
|
||||
container,
|
||||
reloadNonce = 0
|
||||
}: {
|
||||
runtime: SessionRuntime
|
||||
tab: SessionPreviewTab
|
||||
/** The list page, base-stripped. */
|
||||
path: string
|
||||
workspaceId: string
|
||||
/** The tab's element, which also holds the list's portalled drawers and menus. */
|
||||
container: HTMLElement
|
||||
/** Bumped to load the list again from the server. */
|
||||
reloadNonce?: number
|
||||
} = $props()
|
||||
|
||||
// Mounted in the preview panel, outside the chat's own subtree, as PageItemEditorView is.
|
||||
setContext(
|
||||
'aiChatManager',
|
||||
untrack(() => runtime.manager)
|
||||
)
|
||||
setOperatingWorkspace(() => workspaceId)
|
||||
|
||||
// The tab is where the list keeps its filters: written as the tab's observed location, so
|
||||
// the chat reads them and a remount restores them, as it would for a frame.
|
||||
const locationParts = () => {
|
||||
const loc = whereIs(tab).split('#')[0]
|
||||
const at = loc.indexOf('?')
|
||||
return at < 0
|
||||
? { pathname: loc, search: '' }
|
||||
: { pathname: loc.slice(0, at), search: loc.slice(at) }
|
||||
}
|
||||
const host: HostedPage = {
|
||||
get search() {
|
||||
return locationParts().search
|
||||
},
|
||||
setSearch(search) {
|
||||
runtime.previewTabs.observeLocation(tab.id, locationParts().pathname + search)
|
||||
},
|
||||
openItem(itemPath) {
|
||||
const ref = pageItemForListPath(path, itemPath)
|
||||
if (ref) runtime.previewTabs.open({ type: 'pageitem', ref })
|
||||
},
|
||||
openLink(href) {
|
||||
const item = parsePreviewItemRoute(href)
|
||||
if (item) {
|
||||
runtime.previewTabs.open({
|
||||
type: 'item',
|
||||
item: { kind: item.kind, raw_app: item.raw_app, path: item.itemPath, summary: '' }
|
||||
})
|
||||
} else {
|
||||
runtime.previewTabs.open({ type: 'page', href, label: previewLocationLabel(href) })
|
||||
}
|
||||
}
|
||||
}
|
||||
setHostedPage(host)
|
||||
|
||||
// Any in-app link under the tab opens in the session rather than taking the whole window off
|
||||
// it: a list, its drawers and its menus carry plain hrefs throughout (runs, audit logs, a
|
||||
// job). A `#` link is a row's own: its handler opens the row through the host, and following
|
||||
// it would write the row into the sessions page's own hash.
|
||||
$effect(() => {
|
||||
const el = container
|
||||
function onClick(e: MouseEvent) {
|
||||
if (e.defaultPrevented || e.button !== 0) return
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
||||
const a = (e.target as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null
|
||||
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download')) return
|
||||
const url = new URL(a.href, window.location.href)
|
||||
if (url.origin !== window.location.origin) return
|
||||
e.preventDefault()
|
||||
if (a.getAttribute('href')?.startsWith('#')) return
|
||||
host.openLink(url.pathname + url.search + url.hash)
|
||||
}
|
||||
el.addEventListener('click', onClick)
|
||||
return () => el.removeEventListener('click', onClick)
|
||||
})
|
||||
|
||||
const listed = $derived(pageItemForListPath(path, ''))
|
||||
</script>
|
||||
|
||||
{#snippet loading()}
|
||||
<div class="flex-1 flex items-center justify-center text-tertiary">
|
||||
<Loader2 class="animate-spin" />
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- Dynamic imports, as the editors: each list pulls in its item's editor. -->
|
||||
<div class="flex h-full min-h-0 flex-col overflow-auto">
|
||||
{#key `${path}:${workspaceId}:${reloadNonce}`}
|
||||
{#if listed?.kind === 'variable'}
|
||||
{#await import('$lib/components/variables/VariablesList.svelte')}
|
||||
{@render loading()}
|
||||
{:then Module}
|
||||
<Module.default />
|
||||
{/await}
|
||||
{:else if listed?.kind === 'resource'}
|
||||
{#await import('$lib/components/resources/ResourcesList.svelte')}
|
||||
{@render loading()}
|
||||
{:then Module}
|
||||
<Module.default />
|
||||
{/await}
|
||||
{:else if listed?.kind === 'schedule'}
|
||||
{#await import('$lib/components/schedules/SchedulesList.svelte')}
|
||||
{@render loading()}
|
||||
{:then Module}
|
||||
<Module.default />
|
||||
{/await}
|
||||
{:else if listed?.kind === 'trigger'}
|
||||
{#await import('$lib/components/triggers/TriggerList.svelte')}
|
||||
{@render loading()}
|
||||
{:then Module}
|
||||
<Module.default triggerKind={listed.triggerKind} />
|
||||
{/await}
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
@@ -105,6 +105,7 @@
|
||||
|
||||
// A page item's editor reads its draft only when it loads, so a reload remounts it.
|
||||
let pageItemReloadNonce = $state(0)
|
||||
let pageListReloadNonce = $state(0)
|
||||
|
||||
export function reload() {
|
||||
// A live editor shares the runtime store the chat mutates, so generic chat
|
||||
@@ -116,6 +117,10 @@
|
||||
pageItemReloadNonce++
|
||||
return
|
||||
}
|
||||
if (slot.kind === 'pagelist') {
|
||||
pageListReloadNonce++
|
||||
return
|
||||
}
|
||||
try {
|
||||
const win = frame?.contentWindow
|
||||
if (!win) return
|
||||
@@ -334,6 +339,27 @@
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if slot.kind === 'pagelist' && mounted && runtime}
|
||||
<div
|
||||
bind:this={overlayHostEl}
|
||||
class="absolute inset-0 flex flex-col min-h-0 bg-surface {visibility}"
|
||||
aria-hidden={!active}
|
||||
>
|
||||
{#if overlayHostEl}
|
||||
{#await import('./PageListView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{runtime}
|
||||
{tab}
|
||||
path={slot.path}
|
||||
{workspaceId}
|
||||
container={overlayHostEl}
|
||||
reloadNonce={pageListReloadNonce}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if slot.kind === 'artifact' && mounted}
|
||||
<div
|
||||
bind:this={overlayHostEl}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
type TriggerKind
|
||||
} from './previewPaths'
|
||||
import type { OpenInSessionSource } from './OpenInSessionButton.svelte'
|
||||
import { isSessionPreviewFrame } from './sessionMode.svelte'
|
||||
|
||||
// The draft each page's drawer edits. The preview loads the page in its own
|
||||
// document and reads the draft back from the server, so opening a session has to
|
||||
@@ -84,34 +83,6 @@ export function setPageDrawerAnchor(pagePath: string, itemPath: string | undefin
|
||||
history.replaceState(history.state, '', `${pathname}${search}${anchor}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inside a session preview frame, hand a list page row up to the session, which edits it in
|
||||
* a tab of its own. True when handed off: the caller must then not open its drawer. False
|
||||
* off that page, and outside a preview frame, where the drawer is how the row is edited.
|
||||
*/
|
||||
export function handOffPageDrawer(pagePath: string, itemPath: string | undefined): boolean {
|
||||
if (!itemPath || !isSessionPreviewFrame()) return false
|
||||
if (stripBase(window.location.pathname) !== pagePath) return false
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{ type: 'wm.session.openPageItem', pagePath, path: itemPath },
|
||||
window.location.origin
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
// A frame left on a row's hash claims a row nobody has open here, and reopens its tab on
|
||||
// every reload. Not through the router: these pages open their drawer from the hash. Once
|
||||
// more after the event: a row link's `href="#<path>"` lands after its click handler.
|
||||
const dropAnchor = () => {
|
||||
const { pathname, search, hash } = window.location
|
||||
if (hash) history.replaceState(history.state, '', `${pathname}${search}`)
|
||||
}
|
||||
dropAnchor()
|
||||
setTimeout(dropAnchor, 0)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the row a list page deep-links, once its drawer closes. The hash is how the row was
|
||||
* requested; leaving it behind makes the location claim a drawer that is no longer open —
|
||||
|
||||
@@ -79,6 +79,11 @@ export function pageItemListPath(ref: PageItemRef): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a (base-stripped) path is the list page of a page item kind. */
|
||||
export function isPageItemListPath(path: string): boolean {
|
||||
return pageItemForListPath(path, '') !== undefined
|
||||
}
|
||||
|
||||
/** The page item a list page's row names, or undefined for a page that lists none. */
|
||||
export function pageItemForListPath(pagePath: string, path: string): PageItemRef | undefined {
|
||||
const clean = stripBase(pagePath)
|
||||
|
||||
@@ -284,6 +284,17 @@ describe('resolvePreviewTab', () => {
|
||||
expect(resolvePreviewTab('/runs')).toEqual({ kind: 'iframe' })
|
||||
})
|
||||
|
||||
it('mounts the list page of a page item kind in process, whatever its filters', () => {
|
||||
expect(resolvePreviewTab('/variables?path_start=f%2Fa%2F')).toEqual({
|
||||
kind: 'pagelist',
|
||||
path: '/variables'
|
||||
})
|
||||
expect(resolvePreviewTab('/kafka_triggers')).toEqual({
|
||||
kind: 'pagelist',
|
||||
path: '/kafka_triggers'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes any script item to a live editor', () => {
|
||||
expect(resolvePreviewTab('/scripts/edit/f/foo/bar')).toEqual({
|
||||
kind: 'editor',
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
AUDIT_LOGS_PATH,
|
||||
FOLDERS_PATH,
|
||||
GROUPS_PATH,
|
||||
isPageItemListPath,
|
||||
pageItemForListPath,
|
||||
pageItemListPath,
|
||||
pageItemPageHref,
|
||||
@@ -530,13 +531,14 @@ export const isArtifactKey = (key: string) => key.startsWith('artifact:')
|
||||
// fallback. Any editable item of a wrappable kind (script, flow, raw app) mounts
|
||||
// its per-(kind,path) cell editor; a `/pipeline/<folder>` route mounts the
|
||||
// data-pipeline graph editor (single, shared runtime.pipelineEditorState — `path`
|
||||
// is the folder); everything else (static pages, regular drag-and-drop apps, any
|
||||
// other route) stays an iframe.
|
||||
// is the folder); the list page of a page item kind mounts that list; everything
|
||||
// else (static pages, regular drag-and-drop apps, any other route) stays an iframe.
|
||||
export type PreviewSlot =
|
||||
| { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string }
|
||||
| { kind: 'artifact'; id: string; version?: number }
|
||||
| { kind: 'runform'; toolCallId: string }
|
||||
| { kind: 'pageitem'; ref: PageItemRef }
|
||||
| { kind: 'pagelist'; path: string }
|
||||
| { kind: 'iframe' }
|
||||
|
||||
export function resolvePreviewTab(url: string): PreviewSlot {
|
||||
@@ -546,22 +548,24 @@ export function resolvePreviewTab(url: string): PreviewSlot {
|
||||
if (runForm) return { kind: 'runform', toolCallId: runForm.toolCallId }
|
||||
const pageItem = parsePageItemRoute(url)
|
||||
if (pageItem) return { kind: 'pageitem', ref: pageItem }
|
||||
const route = stripBase(url)
|
||||
if (isPageItemListPath(route)) return { kind: 'pagelist', path: route }
|
||||
const pipelineFolder = parsePipelineRoute(url)
|
||||
if (pipelineFolder) {
|
||||
return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder }
|
||||
}
|
||||
const route = parsePreviewItemRoute(url)
|
||||
if (!route) return { kind: 'iframe' }
|
||||
const item = parsePreviewItemRoute(url)
|
||||
if (!item) return { kind: 'iframe' }
|
||||
const editorKind: SessionTargetKind | undefined =
|
||||
route.kind === 'script'
|
||||
item.kind === 'script'
|
||||
? 'script'
|
||||
: route.kind === 'flow'
|
||||
: item.kind === 'flow'
|
||||
? 'flow'
|
||||
: route.kind === 'app' && route.raw_app
|
||||
: item.kind === 'app' && item.raw_app
|
||||
? 'raw_app'
|
||||
: undefined
|
||||
if (!editorKind) return { kind: 'iframe' }
|
||||
return { kind: 'editor', editorKind, path: route.itemPath }
|
||||
return { kind: 'editor', editorKind, path: item.itemPath }
|
||||
}
|
||||
|
||||
/** The full workspace page showing what a tab shows ("Open in workspace"), or undefined when
|
||||
@@ -576,6 +580,7 @@ export function workspacePageHref(location: string): string | undefined {
|
||||
case 'pageitem':
|
||||
return pageItemPageHref(slot.ref)
|
||||
case 'editor':
|
||||
case 'pagelist':
|
||||
case 'iframe':
|
||||
return location
|
||||
default: {
|
||||
|
||||
@@ -406,7 +406,8 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
const tab = owner?.displayedTab
|
||||
if (!tab) return undefined
|
||||
const slotKind = resolvePreviewTab(tab.url).kind
|
||||
if (slotKind !== 'iframe' && slotKind !== 'pageitem') return undefined
|
||||
if (slotKind !== 'iframe' && slotKind !== 'pageitem' && slotKind !== 'pagelist')
|
||||
return undefined
|
||||
return previewLocationContext(whereIs(tab))
|
||||
}
|
||||
// Pre-flight: materialise the (still-transient) session, then commit
|
||||
|
||||
@@ -0,0 +1,983 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, untrack } from 'svelte'
|
||||
import { base } from '$app/paths'
|
||||
import { page } from '$app/stores'
|
||||
import {
|
||||
AzureTriggerService,
|
||||
GcpTriggerService,
|
||||
PostgresTriggerService,
|
||||
SettingService,
|
||||
WorkspaceService,
|
||||
type TriggerMode,
|
||||
type WorkspaceDeployUISettings
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
canWrite,
|
||||
capitalize,
|
||||
copyToClipboard,
|
||||
displayDate,
|
||||
getLocalSetting,
|
||||
removeTriggerKindIfUnused,
|
||||
sendUserToast,
|
||||
storeLocalSetting
|
||||
} from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { enterpriseLicense, usedTriggerKinds, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { goto, setQuery } from '$lib/navigation'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
|
||||
import {
|
||||
Circle,
|
||||
ClipboardCopy,
|
||||
Code,
|
||||
Database,
|
||||
Eye,
|
||||
FileUp,
|
||||
Mail,
|
||||
Pause,
|
||||
Pen,
|
||||
Plus,
|
||||
Route,
|
||||
Shield,
|
||||
Trash,
|
||||
Unplug
|
||||
} from 'lucide-svelte'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import ListFilters from '$lib/components/home/ListFilters.svelte'
|
||||
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import AmqpIcon from '$lib/components/icons/AmqpIcon.svelte'
|
||||
import AwsIcon from '$lib/components/icons/AwsIcon.svelte'
|
||||
import AzureIcon from '$lib/components/icons/AzureIcon.svelte'
|
||||
import GoogleCloudIcon from '$lib/components/icons/GoogleCloudIcon.svelte'
|
||||
import KafkaIcon from '$lib/components/icons/KafkaIcon.svelte'
|
||||
import MqttIcon from '$lib/components/icons/MqttIcon.svelte'
|
||||
import NatsIcon from '$lib/components/icons/NatsIcon.svelte'
|
||||
import TriggerModeToggle from './TriggerModeToggle.svelte'
|
||||
import { getHttpRoute } from './http/utils'
|
||||
import { getEmailAddress, getEmailDomain } from './email/utils'
|
||||
import { TRIGGER_LIST_CONFIG, type TriggerRow } from './triggerListConfig'
|
||||
import type { TriggerKind } from '$lib/components/sessions/previewPaths'
|
||||
import { useHostedPage } from '$lib/components/hostedPage'
|
||||
import {
|
||||
useOperatingUser,
|
||||
useOperatingWorkspace
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
let { triggerKind }: { triggerKind: TriggerKind } = $props()
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const operatingUser = useOperatingUser()
|
||||
const hosted = useHostedPage()
|
||||
|
||||
const config = $derived(TRIGGER_LIST_CONFIG[triggerKind])
|
||||
const isAdmin = $derived(
|
||||
!!(operatingUser.current?.is_admin || operatingUser.current?.is_super_admin)
|
||||
)
|
||||
|
||||
type EditorHandle = {
|
||||
openNew: (isFlow: boolean) => void
|
||||
openEdit: (path: string, isFlow: boolean) => void
|
||||
}
|
||||
type Module = { default: any }
|
||||
const EDITORS: Record<TriggerKind, () => Promise<Module>> = {
|
||||
http: () => import('./http/RouteEditor.svelte'),
|
||||
websocket: () => import('./websocket/WebsocketTriggerEditor.svelte'),
|
||||
postgres: () => import('./postgres/PostgresTriggerEditor.svelte'),
|
||||
kafka: () => import('./kafka/KafkaTriggerEditor.svelte'),
|
||||
nats: () => import('./nats/NatsTriggerEditor.svelte'),
|
||||
sqs: () => import('./sqs/SqsTriggerEditor.svelte'),
|
||||
gcp: () => import('./gcp/GcpTriggerEditor.svelte'),
|
||||
azure: () => import('./azure/AzureTriggerEditor.svelte'),
|
||||
mqtt: () => import('./mqtt/MqttTriggerEditor.svelte'),
|
||||
amqp: () => import('./amqp/AmqpTriggerEditor.svelte'),
|
||||
email: () => import('./email/EmailTriggerEditor.svelte')
|
||||
}
|
||||
const ICONS: Record<TriggerKind, any> = {
|
||||
http: Route,
|
||||
websocket: Unplug,
|
||||
postgres: Database,
|
||||
kafka: KafkaIcon,
|
||||
nats: NatsIcon,
|
||||
sqs: AwsIcon,
|
||||
gcp: GoogleCloudIcon,
|
||||
azure: AzureIcon,
|
||||
mqtt: MqttIcon,
|
||||
amqp: AmqpIcon,
|
||||
email: Mail
|
||||
}
|
||||
const Icon = $derived(ICONS[triggerKind])
|
||||
|
||||
type TriggerW = TriggerRow & { canWrite: boolean }
|
||||
|
||||
let triggers: TriggerW[] = $state([])
|
||||
let loading = $state(true)
|
||||
let editor: EditorHandle | undefined = $state()
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
let routesGenerator: { openDrawer: () => void } | undefined = $state()
|
||||
let openAPISpecGenerator: { openDrawer: () => void } | undefined = $state()
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
let globalHttpWorkspacedRoute = $state(false)
|
||||
let emailDomain: string | null = $state(null)
|
||||
|
||||
function openEdit(path: string, isFlow: boolean) {
|
||||
if (hosted) hosted.openItem(path)
|
||||
else editor?.openEdit(path, isFlow)
|
||||
}
|
||||
|
||||
function openLink(href: string) {
|
||||
if (hosted) hosted.openLink(`${base}${href}`)
|
||||
else goto(href)
|
||||
}
|
||||
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $operatingWorkspace! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
if (untrack(() => triggerKind) === 'http') {
|
||||
SettingService.getGlobal({ key: 'http_route_workspaced_route' })
|
||||
.then((setting) => (globalHttpWorkspacedRoute = (setting as boolean) ?? false))
|
||||
.catch(() => (globalHttpWorkspacedRoute = false))
|
||||
}
|
||||
|
||||
async function loadTriggers(): Promise<void> {
|
||||
const workspace = $operatingWorkspace!
|
||||
const listed = await config.list({ workspace, includeDraftOnly: true })
|
||||
triggers = listed.map((x) => ({
|
||||
canWrite: canWrite(x.path, x.extra_perms!, operatingUser.current),
|
||||
...x
|
||||
}))
|
||||
// The nav rail lists the trigger kinds of the navigation workspace only.
|
||||
if (workspace === $workspaceStore) {
|
||||
$usedTriggerKinds = removeTriggerKindIfUnused(
|
||||
triggers.length,
|
||||
config.usedKind,
|
||||
$usedTriggerKinds
|
||||
)
|
||||
}
|
||||
if (triggerKind === 'email') emailDomain = await getEmailDomain()
|
||||
loading = false
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($operatingWorkspace && operatingUser.current) {
|
||||
untrack(() => loadTriggers())
|
||||
}
|
||||
})
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | undefined = untrack(() => config.status)
|
||||
? setInterval(async () => {
|
||||
try {
|
||||
const newTriggers = await config.list({ workspace: $operatingWorkspace! })
|
||||
for (let i = 0; i < triggers.length; i++) {
|
||||
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
|
||||
if (newTrigger) {
|
||||
triggers[i] = {
|
||||
...triggers[i],
|
||||
error: newTrigger.error,
|
||||
last_server_ping: newTrigger.last_server_ping,
|
||||
mode: newTrigger.mode,
|
||||
server_id: newTrigger.server_id
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, 5000)
|
||||
: undefined
|
||||
onDestroy(() => clearInterval(interval))
|
||||
|
||||
function modeVerb(mode: TriggerMode) {
|
||||
return mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend'
|
||||
}
|
||||
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
|
||||
const workspace = $operatingWorkspace!
|
||||
const label = config.forkConflictLabel
|
||||
if (label === undefined) {
|
||||
try {
|
||||
await config.setMode({ path, workspace, requestBody: { mode } })
|
||||
} catch (err) {
|
||||
sendUserToast(`${config.modeError(modeVerb(mode))}: ${err.body}`, true)
|
||||
} finally {
|
||||
loadTriggers()
|
||||
}
|
||||
return true
|
||||
}
|
||||
let committed = false
|
||||
try {
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) => config.setMode({ path, workspace, requestBody: { mode, force } }),
|
||||
label
|
||||
)
|
||||
if (ok) {
|
||||
if (config.modeToastLabel) {
|
||||
sendUserToast(`${capitalize(mode)} ${config.modeToastLabel} ${path}`)
|
||||
}
|
||||
loadTriggers()
|
||||
}
|
||||
committed = ok
|
||||
} catch (err) {
|
||||
sendUserToast(`${config.modeError(modeVerb(mode))}: ${err.body}`, true)
|
||||
loadTriggers()
|
||||
}
|
||||
return committed
|
||||
}
|
||||
|
||||
async function deleteTrigger(row: TriggerW) {
|
||||
const workspace = $operatingWorkspace ?? ''
|
||||
if (triggerKind === 'postgres' || triggerKind === 'gcp' || triggerKind === 'azure') {
|
||||
isDeleting = false
|
||||
deleteA = false
|
||||
deleteB = false
|
||||
pendingDelete = row
|
||||
return
|
||||
}
|
||||
if (!config.catchDelete) {
|
||||
await config.remove({ workspace, path: row.path })
|
||||
loadTriggers()
|
||||
return
|
||||
}
|
||||
try {
|
||||
await config.remove({ workspace, path: row.path })
|
||||
if (config.deleteToastLabel) {
|
||||
sendUserToast(`Successfully deleted ${config.deleteToastLabel}: ${row.path}`)
|
||||
}
|
||||
loadTriggers()
|
||||
} catch (error) {
|
||||
sendUserToast(error.body || error.message, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Kinds whose trigger owns an external subscription (a replication slot and publication, a
|
||||
// cloud subscription) confirm the delete, offering to drop those too.
|
||||
let pendingDelete: TriggerW | undefined = $state(undefined)
|
||||
let isDeleting = $state(false)
|
||||
let deleteA = $state(false)
|
||||
let deleteB = $state(false)
|
||||
|
||||
async function confirmPendingDelete() {
|
||||
const row = pendingDelete
|
||||
if (!row) return
|
||||
const workspace = $operatingWorkspace ?? ''
|
||||
isDeleting = true
|
||||
const cleanups: (() => Promise<unknown>)[] = []
|
||||
if (triggerKind === 'postgres') {
|
||||
if (deleteA) {
|
||||
cleanups.push(() =>
|
||||
PostgresTriggerService.deletePostgresReplicationSlot({
|
||||
workspace,
|
||||
path: row.postgres_resource_path!,
|
||||
requestBody: { name: row.replication_slot_name! }
|
||||
})
|
||||
)
|
||||
}
|
||||
if (deleteB) {
|
||||
cleanups.push(() =>
|
||||
PostgresTriggerService.deletePostgresPublication({
|
||||
workspace,
|
||||
path: row.postgres_resource_path!,
|
||||
publication: row.publication_name!
|
||||
})
|
||||
)
|
||||
}
|
||||
} else if (triggerKind === 'gcp' && deleteA) {
|
||||
const requestBody = { subscription_id: row.subscription_id!, project_id: row.project_id }
|
||||
cleanups.push(() =>
|
||||
row.gcp_resource_path
|
||||
? GcpTriggerService.deleteGcpSubscription({
|
||||
workspace,
|
||||
path: row.gcp_resource_path,
|
||||
requestBody
|
||||
})
|
||||
: GcpTriggerService.deleteGcpSubscriptionWithDefaultCredentials({
|
||||
workspace,
|
||||
requestBody
|
||||
})
|
||||
)
|
||||
} else if (triggerKind === 'azure' && deleteA && row.subscription_name) {
|
||||
cleanups.push(() =>
|
||||
AzureTriggerService.deleteAzureSubscription({
|
||||
workspace,
|
||||
path: row.azure_resource_path!,
|
||||
requestBody: {
|
||||
azure_mode: row.azure_mode!,
|
||||
scope_resource_id: row.scope_resource_id!,
|
||||
topic_name: row.topic_name ?? undefined,
|
||||
subscription_name: row.subscription_name!
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
for (const cleanup of cleanups) {
|
||||
try {
|
||||
sendUserToast((await cleanup()) as string)
|
||||
} catch (error) {
|
||||
isDeleting = false
|
||||
sendUserToast(error.body || error.message, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
const msg = (await config.remove({ workspace, path: row.path })) as string
|
||||
sendUserToast(msg)
|
||||
loadTriggers()
|
||||
pendingDelete = undefined
|
||||
}
|
||||
|
||||
// A host opens rows itself, and the document's hash is not this page's.
|
||||
let hashHandled = false
|
||||
$effect(() => {
|
||||
if (hosted) return
|
||||
if (!hashHandled && triggers.length > 0 && editor) {
|
||||
let hash = $page.url.hash
|
||||
if (hash.length > 1) {
|
||||
let path = hash.slice(1)
|
||||
let trigger = triggers.find((t) => t.path === path)
|
||||
if (trigger) {
|
||||
hashHandled = true
|
||||
editor?.openEdit(path, trigger.is_flow)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let filteredItems: (TriggerW & { marked?: any })[] | undefined = $state([])
|
||||
let filter = $state('')
|
||||
let ownerFilter: string | undefined = $state(undefined)
|
||||
let nbDisplayed = $state(15)
|
||||
|
||||
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
|
||||
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
|
||||
let selectedFilterKind = $state(
|
||||
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
|
||||
)
|
||||
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
|
||||
|
||||
$effect(() => {
|
||||
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
|
||||
})
|
||||
$effect(() => {
|
||||
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
|
||||
})
|
||||
|
||||
// Read before the first write below, which would otherwise replace what the location asked for.
|
||||
function loadQueryFilters(search: string) {
|
||||
const params = new URLSearchParams(search)
|
||||
const queryFilterKind = params.get(TRIGGER_PATH_KIND_FILTER_SETTING)
|
||||
const queryFilterUserFolders = params.get(FILTER_USER_FOLDER_SETTING_NAME)
|
||||
if (queryFilterKind) selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
|
||||
if (queryFilterUserFolders) filterUserFolders = queryFilterUserFolders == 'true'
|
||||
}
|
||||
untrack(() => loadQueryFilters(hosted ? hosted.search : window.location.search))
|
||||
$effect(() => {
|
||||
if (!hosted) return
|
||||
const search = hosted.search
|
||||
untrack(() => loadQueryFilters(search))
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const kind = selectedFilterKind
|
||||
const userFolders = String(filterUserFolders)
|
||||
untrack(() => {
|
||||
if (hosted) {
|
||||
const params = new URLSearchParams(hosted.search)
|
||||
params.set(TRIGGER_PATH_KIND_FILTER_SETTING, kind)
|
||||
params.set(FILTER_USER_FOLDER_SETTING_NAME, userFolders)
|
||||
const next = `?${params}`
|
||||
if (next !== hosted.search) hosted.setSearch(next)
|
||||
return
|
||||
}
|
||||
setQuery(TRIGGER_PATH_KIND_FILTER_SETTING, kind, window.location.hash || undefined).then(
|
||||
() => {
|
||||
setQuery(FILTER_USER_FOLDER_SETTING_NAME, userFolders, window.location.hash || undefined)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function filterItemsPathsBaseOnUserFilters(item: TriggerW) {
|
||||
if ($operatingWorkspace == 'admins') return true
|
||||
if (!filterUserFolders) return true
|
||||
const path = selectedFilterKind === 'trigger' ? item.path : item.script_path
|
||||
return !path.startsWith('u/') || path.startsWith('u/' + operatingUser.current?.username + '/')
|
||||
}
|
||||
|
||||
const preFilteredItems = $derived(
|
||||
triggers.filter(
|
||||
(x) =>
|
||||
(ownerFilter == undefined ||
|
||||
(selectedFilterKind === 'trigger' ? x.path : x.script_path).startsWith(
|
||||
ownerFilter + '/'
|
||||
)) &&
|
||||
filterItemsPathsBaseOnUserFilters(x)
|
||||
)
|
||||
)
|
||||
|
||||
let lastWorkspace = untrack(() => $operatingWorkspace)
|
||||
$effect.pre(() => {
|
||||
const workspace = $operatingWorkspace
|
||||
if (workspace === lastWorkspace) return
|
||||
lastWorkspace = workspace
|
||||
ownerFilter = undefined
|
||||
})
|
||||
|
||||
const owners = $derived(
|
||||
Array.from(
|
||||
new Set(
|
||||
(filteredItems ?? [])
|
||||
.map((x) => (selectedFilterKind === 'trigger' ? x.path : x.script_path))
|
||||
.filter(Boolean)
|
||||
.map((p) => p.split('/').slice(0, 2).join('/'))
|
||||
)
|
||||
).sort()
|
||||
)
|
||||
|
||||
const items = $derived(filter !== '' ? filteredItems : preFilteredItems)
|
||||
|
||||
// A push delivery has no connection to report and no consumer to suspend: it is an endpoint.
|
||||
function isLive(row: TriggerRow): boolean {
|
||||
if (triggerKind === 'gcp') return row.delivery_type !== 'push'
|
||||
if (triggerKind === 'azure') return row.azure_mode === 'namespace_pull'
|
||||
return true
|
||||
}
|
||||
|
||||
function copyUrl(row: TriggerRow): { label: string; value: string } | undefined {
|
||||
switch (triggerKind) {
|
||||
case 'http':
|
||||
return {
|
||||
label: 'Copy URL',
|
||||
value: getHttpRoute(
|
||||
'r',
|
||||
row.route_path,
|
||||
(row.workspaced_route ?? false) || globalHttpWorkspacedRoute,
|
||||
row.workspace_id!
|
||||
)
|
||||
}
|
||||
case 'gcp':
|
||||
case 'azure':
|
||||
return isLive(row)
|
||||
? undefined
|
||||
: {
|
||||
label: 'Copy URL',
|
||||
value: getHttpRoute(`${triggerKind}/w`, row.path, true, row.workspace_id!)
|
||||
}
|
||||
case 'email':
|
||||
return { label: 'Copy email address', value: emailAddress(row) }
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function emailAddress(row: TriggerRow) {
|
||||
return getEmailAddress(
|
||||
row.local_part,
|
||||
row.workspaced_local_part ?? false,
|
||||
row.workspace_id!,
|
||||
emailDomain ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
function websocketUrl(url: string) {
|
||||
return url.startsWith('$script:')
|
||||
? 'URL: ' + url.replace('$script:', 'result of script ')
|
||||
: url.startsWith('$flow:')
|
||||
? 'URL: ' + url.replace('$flow:', 'result of flow ')
|
||||
: url
|
||||
}
|
||||
|
||||
const operatorBlocked = $derived(
|
||||
config.operatorGate &&
|
||||
!!operatingUser.current?.operator &&
|
||||
!!$operatingWorkspace &&
|
||||
!$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.triggers
|
||||
)
|
||||
const canCreate = $derived(!config.adminOnly || isAdmin)
|
||||
</script>
|
||||
|
||||
{#snippet rowTitle(row: TriggerW & { marked?: any }, hasDraft: boolean | undefined)}
|
||||
{@const marked = ['http', 'websocket', 'kafka', 'nats', 'email'].includes(triggerKind)
|
||||
? row.marked
|
||||
: undefined}
|
||||
<div class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate">
|
||||
{#if marked}
|
||||
<span class="text-xs">
|
||||
{@html marked}
|
||||
</span>
|
||||
{:else if triggerKind === 'http'}
|
||||
{#if row.summary}
|
||||
{row.summary}
|
||||
{:else}
|
||||
{row.http_method!.toUpperCase()}
|
||||
/{isCloudHosted() || row.workspaced_route || globalHttpWorkspacedRoute
|
||||
? row.workspace_id + '/' + row.route_path
|
||||
: row.route_path}
|
||||
{/if}
|
||||
{:else if triggerKind === 'websocket'}
|
||||
{websocketUrl(row.url!)}
|
||||
{:else if triggerKind === 'kafka'}
|
||||
{row.kafka_resource_path} - {row.topics!.join(', ')}
|
||||
{:else if triggerKind === 'nats'}
|
||||
{row.nats_resource_path} - {row.subjects!.join(', ')}
|
||||
{:else if triggerKind === 'email'}
|
||||
{emailAddress(row)}
|
||||
{:else if triggerKind === 'gcp'}
|
||||
{row.path} - {row.topic_id}
|
||||
{:else if triggerKind === 'azure'}
|
||||
{row.path} - {row.topic_name
|
||||
? row.topic_name
|
||||
: (row.scope_resource_id?.split('/')?.pop() ?? '')} ({row.azure_mode}, {row.subscription_name})
|
||||
{:else}
|
||||
{row.path}
|
||||
{/if}{hasDraft ? '*' : ''}
|
||||
</div>
|
||||
{#if ['http', 'websocket', 'kafka', 'nats', 'email'].includes(triggerKind)}
|
||||
<div class="text-secondary text-xs truncate text-left font-light">{row.path}</div>
|
||||
{:else if triggerKind === 'postgres'}
|
||||
<div class="text-secondary text-xs truncate text-left font-light">
|
||||
{row.postgres_resource_path}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-secondary text-xs truncate text-left font-light">
|
||||
{#if triggerKind === 'http' && row.static_asset_config}
|
||||
file: {row.static_asset_config.s3}
|
||||
{:else}
|
||||
runnable: {row.script_path}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet statusDot(row: TriggerW)}
|
||||
{@const status = config.status}
|
||||
{#if status}
|
||||
{@const ping = status.pings
|
||||
? row.last_server_ping
|
||||
? new Date(row.last_server_ping)
|
||||
: undefined
|
||||
: new Date()}
|
||||
{@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000}
|
||||
{@const effectiveMode = row.draft_only ? 'disabled' : row.mode}
|
||||
{@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'}
|
||||
<div class="w-10">
|
||||
{#if (enabled && (!pinging || row.error)) || (!enabled && row.error) || (enabled && !row.server_id)}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle class="text-red-600 animate-ping absolute inline-flex fill-current" size={12} />
|
||||
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
{#if enabled}
|
||||
{#if !row.server_id}
|
||||
{status.starting}
|
||||
{:else}
|
||||
{status.notConnected}{row.error ? ': ' + row.error : ''}
|
||||
{/if}
|
||||
{:else}
|
||||
{status.disabled}: {row.error}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else if enabled}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle class="text-green-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
{status.connected}{status.shuttingDown && !row.server_id ? ' (shutting down...)' : ''}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<ConfirmationModal
|
||||
open={pendingDelete !== undefined}
|
||||
title={triggerKind === 'postgres'
|
||||
? 'Delete Postgres trigger'
|
||||
: triggerKind === 'gcp'
|
||||
? 'Delete GCP Pub/Sub trigger'
|
||||
: 'Delete Azure Event Grid trigger'}
|
||||
confirmationText="Remove"
|
||||
loading={isDeleting}
|
||||
on:canceled={() => {
|
||||
isDeleting = false
|
||||
pendingDelete = undefined
|
||||
}}
|
||||
on:confirmed={confirmPendingDelete}
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
<span>Are you sure you want to remove this trigger?</span>
|
||||
{#if pendingDelete && triggerKind === 'postgres'}
|
||||
<Toggle
|
||||
options={{
|
||||
left: `Delete the associated replication slot named: ${pendingDelete.replication_slot_name} ?`
|
||||
}}
|
||||
bind:checked={deleteA}
|
||||
/>
|
||||
<Toggle
|
||||
options={{
|
||||
left: `Delete the associated publication named: ${pendingDelete.publication_name} ?`
|
||||
}}
|
||||
bind:checked={deleteB}
|
||||
/>
|
||||
{:else if pendingDelete && triggerKind === 'gcp'}
|
||||
<Toggle
|
||||
options={{
|
||||
left: `Delete subscription "${pendingDelete.subscription_id}" subscribed to topic "${pendingDelete.topic_id}"?`
|
||||
}}
|
||||
bind:checked={deleteA}
|
||||
/>
|
||||
{:else if pendingDelete?.subscription_name && triggerKind === 'azure'}
|
||||
<Toggle
|
||||
options={{
|
||||
left: `Also delete Azure subscription "${pendingDelete.subscription_name}" on Azure?`
|
||||
}}
|
||||
bind:checked={deleteA}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
{#await EDITORS[triggerKind]() then Editor}
|
||||
<Editor.default onUpdate={loadTriggers} bind:this={editor} />
|
||||
{/await}
|
||||
{#if triggerKind === 'http'}
|
||||
{#await import('./http/RoutesGenerator.svelte') then Generator}
|
||||
<Generator.default closeFn={loadTriggers} bind:this={routesGenerator} />
|
||||
{/await}
|
||||
{#await import('./http/OpenAPISpecGenerator.svelte') then Generator}
|
||||
<Generator.default bind:this={openAPISpecGenerator} />
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={preFilteredItems}
|
||||
bind:filteredItems
|
||||
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
|
||||
/>
|
||||
|
||||
{#if operatorBlocked}
|
||||
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
|
||||
<p class="font-bold">Unauthorized</p>
|
||||
<p>Page not available for operators</p>
|
||||
</div>
|
||||
{:else}
|
||||
<CenteredPage>
|
||||
<PageHeader
|
||||
title={config.title}
|
||||
tooltip={config.tooltip}
|
||||
documentationLink={config.documentationLink}
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
{#if triggerKind === 'http'}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => routesGenerator?.openDrawer()}
|
||||
>
|
||||
From OpenAPI spec
|
||||
</Button>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => openAPISpecGenerator?.openDrawer()}
|
||||
>
|
||||
To OpenAPI spec
|
||||
</Button>
|
||||
{/if}
|
||||
{#if canCreate}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => editor?.openNew(false)}
|
||||
>
|
||||
New {config.newLabel}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{#if config.cloudDisabled && isCloudHosted()}
|
||||
<Alert title="Not compatible with multi-tenant cloud" type="warning">
|
||||
{config.title} are disabled in the multi-tenant cloud.
|
||||
</Alert>
|
||||
<div class="py-4"></div>
|
||||
{/if}
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<div class="w-full pb-4 pt-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={config.searchPlaceholder}
|
||||
bind:value={filter}
|
||||
class="search-item"
|
||||
/>
|
||||
<div class="flex flex-row items-center gap-2 mt-2">
|
||||
<div class="text-xs font-semibold text-emphasis shrink-0"> Filter by path of </div>
|
||||
<ToggleButtonGroup bind:selected={selectedFilterKind}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="trigger" label={config.filterLabel} icon={Icon} {item} />
|
||||
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
|
||||
|
||||
<div class="flex flex-row items-center justify-end gap-4">
|
||||
{#if operatingUser.current?.is_super_admin && operatingUser.current.username.includes('@')}
|
||||
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
|
||||
{:else if isAdmin}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={filterUserFolders}
|
||||
options={{ right: `Only u/${operatingUser.current?.username} and f/*` }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if loading}
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[6], 0.4]} />
|
||||
{/each}
|
||||
{:else if !triggers?.length}
|
||||
<EmptyState
|
||||
icon={Icon}
|
||||
title={config.empty.title}
|
||||
description={config.empty.description}
|
||||
action={canCreate
|
||||
? {
|
||||
label: config.empty.actionLabel,
|
||||
icon: Plus,
|
||||
onClick: () => editor?.openNew(false),
|
||||
aiId: config.empty.aiId,
|
||||
aiDescription: config.empty.aiDescription
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
{:else if items?.length}
|
||||
<div class="border rounded-md divide-y">
|
||||
{#each items.slice(0, nbDisplayed) as row (row.path)}
|
||||
{@const {
|
||||
path,
|
||||
edited_by,
|
||||
edited_at,
|
||||
script_path,
|
||||
is_flow,
|
||||
extra_perms,
|
||||
canWrite,
|
||||
mode,
|
||||
retry,
|
||||
error_handler_path,
|
||||
error_handler_args,
|
||||
labels,
|
||||
draft_only,
|
||||
is_draft
|
||||
} = row}
|
||||
{@const hasDraft =
|
||||
getLocalDraftHint($operatingWorkspace, config.draftKind, path) ?? is_draft}
|
||||
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
|
||||
{@const effectiveMode = draft_only ? 'disabled' : mode}
|
||||
{@const live = isLive(row)}
|
||||
{@const copy = copyUrl(row)}
|
||||
|
||||
<div
|
||||
class="bg-surface-tertiary hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
|
||||
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
|
||||
>
|
||||
<div class="w-full flex gap-5 items-center">
|
||||
<RowIcon kind={is_flow ? 'flow' : 'script'} />
|
||||
|
||||
<a
|
||||
href="#{path}"
|
||||
onclick={(e) => {
|
||||
if (hosted) e.preventDefault()
|
||||
openEdit(path, is_flow)
|
||||
}}
|
||||
class="min-w-0 grow hover:underline decoration-gray-400"
|
||||
>
|
||||
{@render rowTitle(row, hasDraft)}
|
||||
</a>
|
||||
|
||||
<div class="hidden lg:flex flex-row gap-1 items-center">
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
{#if triggerKind !== 'http' && triggerKind !== 'azure' && labels?.length}
|
||||
{#each labels as label}
|
||||
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if live}
|
||||
{@render statusDot(row)}
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
|
||||
<DraftBadge {draft_only} is_draft={hasDraft} />
|
||||
{#if live}
|
||||
<TriggerModeToggle
|
||||
disabled={draft_only}
|
||||
title={draft_only
|
||||
? 'Draft only: deploy the trigger to enable it'
|
||||
: hasDraft
|
||||
? 'Enables/disables the deployed trigger; the draft is not affected'
|
||||
: undefined}
|
||||
onToggleMode={(newMode) => onToggleMode(path, newMode)}
|
||||
triggerMode={effectiveMode}
|
||||
includeModalConfig={{
|
||||
triggerPath: path,
|
||||
triggerKind,
|
||||
runnableConfig: {
|
||||
path: script_path,
|
||||
kind: is_flow ? 'flow' : 'script',
|
||||
retry,
|
||||
errorHandlerPath: error_handler_path,
|
||||
errorHandlerArgs: error_handler_args
|
||||
}
|
||||
}}
|
||||
{canWrite}
|
||||
hideToggleLabels
|
||||
hideDropdown
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center justify-end">
|
||||
{#if copy}
|
||||
<Button
|
||||
on:click={() => copyToClipboard(copy.value)}
|
||||
variant="subtle"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: ClipboardCopy }}
|
||||
>
|
||||
{copy.label}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
on:click={() => openEdit(path, is_flow)}
|
||||
unifiedSize="md"
|
||||
startIcon={canWrite ? { icon: Pen } : { icon: Eye }}
|
||||
variant="subtle"
|
||||
>
|
||||
{canWrite ? 'Edit' : 'View'}
|
||||
</Button>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
|
||||
icon: Eye,
|
||||
action: () => openLink(href)
|
||||
},
|
||||
...(canWrite && !draft_only && mode !== 'suspended'
|
||||
? [
|
||||
{
|
||||
displayName: 'Suspend job execution',
|
||||
icon: Pause,
|
||||
action: () => {
|
||||
onToggleMode(path, 'suspended')
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: canWrite ? 'Edit' : 'View',
|
||||
icon: canWrite ? Pen : Eye,
|
||||
action: () => openEdit(path, is_flow)
|
||||
},
|
||||
...(isDeployable('trigger', path, deployUiSettings)
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer?.openDrawer(path, 'trigger', {
|
||||
triggers: { kind: config.deployKind }
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Audit logs',
|
||||
icon: Eye,
|
||||
href: `${base}/audit_logs?resource=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
action: () => {
|
||||
shareModal?.openDrawer(path, config.shareKind as any)
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
type: 'delete',
|
||||
icon: Trash,
|
||||
disabled: !canWrite || (config.adminOnly && !isAdmin),
|
||||
action: () => deleteTrigger(row)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-end items-baseline">
|
||||
<div
|
||||
class="flex flex-wrap text-2xs font-normal text-secondary gap-1 items-center justify-end truncate pr-2"
|
||||
>
|
||||
{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}
|
||||
<div class="truncate">{edited_by ? 'at ' : ''}{displayDate(edited_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<NoItemFound />
|
||||
{/if}
|
||||
</div>
|
||||
{#if items && items?.length > 15 && nbDisplayed < items.length}
|
||||
<span class="text-xs font-normal text-primary"
|
||||
>{nbDisplayed} items out of {items.length}
|
||||
<button class="ml-4 font-semibold text-emphasis" onclick={() => (nbDisplayed += 30)}
|
||||
>load 30 more</button
|
||||
></span
|
||||
>
|
||||
{/if}
|
||||
</CenteredPage>
|
||||
{/if}
|
||||
|
||||
<ShareModal
|
||||
bind:this={shareModal}
|
||||
on:change={() => {
|
||||
loadTriggers()
|
||||
}}
|
||||
/>
|
||||
@@ -2,7 +2,6 @@
|
||||
import { untrack } from 'svelte'
|
||||
import {
|
||||
clearPageDrawerAnchor,
|
||||
handOffPageDrawer,
|
||||
setPageDrawerAnchor
|
||||
} from '$lib/components/sessions/pageDrawerSession'
|
||||
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
|
||||
@@ -176,7 +175,6 @@
|
||||
defaultConfig?: Record<string, any>,
|
||||
fixedScriptPath_?: string
|
||||
) {
|
||||
if (handOffPageDrawer(TRIGGER_PAGES.amqp.path, ePath)) return
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
let loadingTimeout = setTimeout(() => {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import {
|
||||
clearPageDrawerAnchor,
|
||||
handOffPageDrawer,
|
||||
setPageDrawerAnchor
|
||||
} from '$lib/components/sessions/pageDrawerSession'
|
||||
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
|
||||
@@ -156,7 +155,6 @@
|
||||
isFlow: boolean,
|
||||
defaultValues?: Record<string, any>
|
||||
) {
|
||||
if (handOffPageDrawer(TRIGGER_PAGES.azure.path, ePath)) return
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
drawerLoading = true
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { Button } from '$lib/components/common'
|
||||
import {
|
||||
clearPageDrawerAnchor,
|
||||
handOffPageDrawer,
|
||||
setPageDrawerAnchor
|
||||
} from '$lib/components/sessions/pageDrawerSession'
|
||||
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
|
||||
@@ -143,7 +142,6 @@
|
||||
defaultConfig?: Partial<NewEmailTrigger>,
|
||||
fixedScriptPath_?: string
|
||||
) {
|
||||
if (handOffPageDrawer(TRIGGER_PAGES.email.path, ePath)) return
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
drawerLoading = true
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import {
|
||||
clearPageDrawerAnchor,
|
||||
handOffPageDrawer,
|
||||
setPageDrawerAnchor
|
||||
} from '$lib/components/sessions/pageDrawerSession'
|
||||
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
|
||||
@@ -167,7 +166,6 @@
|
||||
defaultValues?: Record<string, any>,
|
||||
fixedScriptPath_?: string
|
||||
) {
|
||||
if (handOffPageDrawer(TRIGGER_PAGES.gcp.path, ePath)) return
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
drawerLoading = true
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { Button } from '$lib/components/common'
|
||||
import {
|
||||
clearPageDrawerAnchor,
|
||||
handOffPageDrawer,
|
||||
setPageDrawerAnchor
|
||||
} from '$lib/components/sessions/pageDrawerSession'
|
||||
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
|
||||
@@ -274,7 +273,6 @@
|
||||
isFlow: boolean,
|
||||
defaultConfig?: Partial<NewHttpTrigger>
|
||||
) {
|
||||
if (handOffPageDrawer(TRIGGER_PAGES.http.path, ePath)) return
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
drawerLoading = true
|
||||
|
||||
@@ -16,16 +16,20 @@
|
||||
import RouteEditor from './RouteEditor.svelte'
|
||||
import { generateHttpTriggerFromOpenApi, type Source } from './utils'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { usedTriggerKinds, userStore } from '$lib/stores'
|
||||
import { usedTriggerKinds } from '$lib/stores'
|
||||
import FileInput from '../../common/fileInput/FileInput.svelte'
|
||||
import { emptyStringTrimmed, sendUserToast } from '$lib/utils'
|
||||
import FolderPicker from '../../FolderPicker.svelte'
|
||||
import Required from '$lib/components/Required.svelte'
|
||||
import { Drawer, DrawerContent } from '$lib/components/common'
|
||||
import { get } from 'svelte/store'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import {
|
||||
useOperatingUser,
|
||||
useOperatingWorkspace
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const operatingUser = useOperatingUser()
|
||||
|
||||
type Props = {
|
||||
closeFn: () => Promise<void>
|
||||
@@ -137,7 +141,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
let userIsAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
|
||||
let userIsAdmin = $derived(
|
||||
operatingUser.current?.is_admin || operatingUser.current?.is_super_admin
|
||||
)
|
||||
|
||||
async function generateHttpTrigger() {
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import {
|
||||
clearPageDrawerAnchor,
|
||||
handOffPageDrawer,
|
||||
setPageDrawerAnchor
|
||||
} from '$lib/components/sessions/pageDrawerSession'
|
||||
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
|
||||
@@ -178,7 +177,6 @@
|
||||
defaultConfig?: Record<string, any>,
|
||||
fixedScriptPath_?: string
|
||||
) {
|
||||
if (handOffPageDrawer(TRIGGER_PAGES.kafka.path, ePath)) return
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
let loadingTimeout = setTimeout(() => {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { untrack } from 'svelte'
|
||||
import {
|
||||
clearPageDrawerAnchor,
|
||||
handOffPageDrawer,
|
||||
setPageDrawerAnchor
|
||||
} from '$lib/components/sessions/pageDrawerSession'
|
||||
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
|
||||
@@ -173,7 +172,6 @@
|
||||
defaultConfig?: Record<string, any>,
|
||||
fixedScriptPath_?: string
|
||||
) {
|
||||
if (handOffPageDrawer(TRIGGER_PAGES.mqtt.path, ePath)) return
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
let loadingTimeout = setTimeout(() => {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import {
|
||||
clearPageDrawerAnchor,
|
||||
handOffPageDrawer,
|
||||
setPageDrawerAnchor
|
||||
} from '$lib/components/sessions/pageDrawerSession'
|
||||
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
|
||||
@@ -161,7 +160,6 @@
|
||||
defaultConfig?: Record<string, any>,
|
||||
fixedScriptPath_?: string
|
||||
) {
|
||||
if (handOffPageDrawer(TRIGGER_PAGES.nats.path, ePath)) return
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
let loadingTimeout = setTimeout(() => {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { Alert, Button, TabContent } from '$lib/components/common'
|
||||
import {
|
||||
clearPageDrawerAnchor,
|
||||
handOffPageDrawer,
|
||||
setPageDrawerAnchor
|
||||
} from '$lib/components/sessions/pageDrawerSession'
|
||||
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
|
||||
@@ -262,7 +261,6 @@
|
||||
defaultConfig?: Record<string, any>,
|
||||
fixedScriptPath_?: string
|
||||
) {
|
||||
if (handOffPageDrawer(TRIGGER_PAGES.postgres.path, ePath)) return
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
let loadingTimeout = setTimeout(() => {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { Alert, Badge, Button, ButtonType, Tab, Tabs } from '$lib/components/common'
|
||||
import {
|
||||
clearPageDrawerAnchor,
|
||||
handOffPageDrawer,
|
||||
setPageDrawerAnchor
|
||||
} from '$lib/components/sessions/pageDrawerSession'
|
||||
import { SCHEDULES_PATH } from '$lib/components/sessions/previewPaths'
|
||||
@@ -181,7 +180,6 @@
|
||||
defaultCfg?: Record<string, any>,
|
||||
fixedScriptPath_?: string
|
||||
) {
|
||||
if (handOffPageDrawer(SCHEDULES_PATH, ePath)) return
|
||||
let loadingTimeout = setTimeout(() => {
|
||||
showLoading = true
|
||||
}, 100) // Do not show loading spinner for the first 100ms
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import {
|
||||
clearPageDrawerAnchor,
|
||||
handOffPageDrawer,
|
||||
setPageDrawerAnchor
|
||||
} from '$lib/components/sessions/pageDrawerSession'
|
||||
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
|
||||
@@ -156,7 +155,6 @@
|
||||
defaultConfig?: Record<string, any>,
|
||||
fixedScriptPath_?: string
|
||||
) {
|
||||
if (handOffPageDrawer(TRIGGER_PAGES.sqs.path, ePath)) return
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
let loadingTimeout = setTimeout(() => {
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
import {
|
||||
AmqpTriggerService,
|
||||
AzureTriggerService,
|
||||
EmailTriggerService,
|
||||
GcpTriggerService,
|
||||
HttpTriggerService,
|
||||
KafkaTriggerService,
|
||||
MqttTriggerService,
|
||||
NatsTriggerService,
|
||||
PostgresTriggerService,
|
||||
SqsTriggerService,
|
||||
WebsocketTriggerService,
|
||||
type AmqpTrigger,
|
||||
type AzureTrigger,
|
||||
type EmailTrigger,
|
||||
type GcpTrigger,
|
||||
type HttpTrigger,
|
||||
type KafkaTrigger,
|
||||
type MqttTrigger,
|
||||
type NatsTrigger,
|
||||
type PostgresTrigger,
|
||||
type SqsTrigger,
|
||||
type TriggerMode,
|
||||
type UserDraftItemKind,
|
||||
type WebsocketTrigger
|
||||
} from '$lib/gen'
|
||||
import type { TriggerKind as UsedTriggerKind } from '$lib/components/triggers'
|
||||
import type { TriggerKind } from '$lib/components/sessions/previewPaths'
|
||||
|
||||
type AnyTrigger =
|
||||
| HttpTrigger
|
||||
| WebsocketTrigger
|
||||
| PostgresTrigger
|
||||
| KafkaTrigger
|
||||
| NatsTrigger
|
||||
| MqttTrigger
|
||||
| AmqpTrigger
|
||||
| SqsTrigger
|
||||
| GcpTrigger
|
||||
| AzureTrigger
|
||||
| EmailTrigger
|
||||
type KeysOfUnion<T> = T extends unknown ? keyof T : never
|
||||
type FieldOf<T, K extends PropertyKey> = T extends unknown
|
||||
? K extends keyof T
|
||||
? T[K]
|
||||
: never
|
||||
: never
|
||||
|
||||
/** A row of any trigger list: the fields every kind shares, plus every kind's own as optional,
|
||||
* so a field read by one kind's branch is still checked against the generated types. */
|
||||
export type TriggerRow = Pick<
|
||||
HttpTrigger,
|
||||
'path' | 'script_path' | 'is_flow' | 'mode' | 'edited_by' | 'edited_at'
|
||||
> & {
|
||||
[K in Exclude<
|
||||
KeysOfUnion<AnyTrigger>,
|
||||
'path' | 'script_path' | 'is_flow' | 'mode' | 'edited_by' | 'edited_at'
|
||||
>]?: FieldOf<AnyTrigger, K>
|
||||
}
|
||||
|
||||
type Call<A> = (a: A) => Promise<unknown>
|
||||
|
||||
export type TriggerListConfig = {
|
||||
/** Page title, and the kind's plural name in the cloud alert. */
|
||||
title: string
|
||||
tooltip: string
|
||||
documentationLink?: string
|
||||
/** Follows "New" on the create button. */
|
||||
newLabel: string
|
||||
searchPlaceholder: string
|
||||
/** The "Filter by path of" choice naming the trigger itself. */
|
||||
filterLabel: string
|
||||
empty: {
|
||||
title: string
|
||||
description: string
|
||||
actionLabel: string
|
||||
aiId: string
|
||||
aiDescription: string
|
||||
}
|
||||
/** Disabled in the multi-tenant cloud. */
|
||||
cloudDisabled: boolean
|
||||
/** Hidden from operators unless the workspace grants them triggers. */
|
||||
operatorGate: boolean
|
||||
/** Creating and deleting are for admins only. */
|
||||
adminOnly: boolean
|
||||
usedKind: UsedTriggerKind
|
||||
deployKind: UsedTriggerKind
|
||||
shareKind: string
|
||||
draftKind: UserDraftItemKind
|
||||
/** Kinds holding a live connection: polled, with a status dot. Each message is followed by
|
||||
* the error or state it reports. */
|
||||
status?: {
|
||||
starting: string
|
||||
notConnected: string
|
||||
disabled: string
|
||||
connected: string
|
||||
/** Reports a connection whose server is going away. */
|
||||
shuttingDown: boolean
|
||||
/** The server stamps a ping; without one, a connected server counts as alive. */
|
||||
pings: boolean
|
||||
}
|
||||
/** Mode changes run through the fork-conflict check, under this name. Kinds whose runtime
|
||||
* address is workspace-prefixed skip it: a fork and its parent never collide. */
|
||||
forkConflictLabel?: string
|
||||
/** Toast a mode change that went through, as "<Mode> <label> <path>". */
|
||||
modeToastLabel?: string
|
||||
/** A failed mode change, followed by ": <error>". */
|
||||
modeError: (verb: 'enable' | 'disable' | 'suspend') => string
|
||||
/** Report a delete through a toast, as "Successfully deleted <label>: <path>". */
|
||||
deleteToastLabel?: string
|
||||
/** Report a failed delete through a toast. */
|
||||
catchDelete: boolean
|
||||
list: (a: { workspace: string; includeDraftOnly?: boolean }) => Promise<TriggerRow[]>
|
||||
setMode: Call<{
|
||||
workspace: string
|
||||
path: string
|
||||
requestBody: { mode: TriggerMode; force?: boolean }
|
||||
}>
|
||||
remove: Call<{ workspace: string; path: string }>
|
||||
}
|
||||
|
||||
const brokerTooltip = (name: string) =>
|
||||
`Windmill can connect to an ${name} broker, subscribe to specific topics, and trigger scripts or flows based on those topics.`
|
||||
|
||||
const consumerStatus = {
|
||||
starting: 'Consumer is starting...',
|
||||
notConnected: 'Consumer is not connected',
|
||||
disabled: 'Consumer was disabled because of an error',
|
||||
connected: 'Consumer is connected',
|
||||
shuttingDown: false,
|
||||
pings: true
|
||||
}
|
||||
|
||||
export const TRIGGER_LIST_CONFIG: Record<TriggerKind, TriggerListConfig> = {
|
||||
http: {
|
||||
title: 'Custom HTTP routes',
|
||||
tooltip:
|
||||
'Every script and flow already has a canonical HTTP API endpoint/webhook attached to it, this is to create additional parametrizable ones.',
|
||||
documentationLink: 'https://www.windmill.dev/docs/core_concepts/http_routing',
|
||||
newLabel: 'route',
|
||||
searchPlaceholder: 'Search routes',
|
||||
filterLabel: 'Route',
|
||||
empty: {
|
||||
title: 'No custom HTTP routes yet',
|
||||
description:
|
||||
'Every script and flow already has a canonical HTTP endpoint attached to it. These are additional parametrizable ones.',
|
||||
actionLabel: 'Add a route',
|
||||
aiId: 'routes-empty-add',
|
||||
aiDescription: 'Add route'
|
||||
},
|
||||
cloudDisabled: false,
|
||||
operatorGate: true,
|
||||
adminOnly: false,
|
||||
usedKind: 'routes',
|
||||
deployKind: 'routes',
|
||||
shareKind: 'http_trigger',
|
||||
draftKind: 'trigger_http',
|
||||
modeError: (verb) => `Cannot ${verb} http trigger`,
|
||||
deleteToastLabel: 'HTTP route',
|
||||
catchDelete: true,
|
||||
list: (a) => HttpTriggerService.listHttpTriggers(a),
|
||||
setMode: (a) => HttpTriggerService.setHttpTriggerMode(a),
|
||||
remove: (a) => HttpTriggerService.deleteHttpTrigger(a)
|
||||
},
|
||||
websocket: {
|
||||
title: 'WebSocket triggers',
|
||||
tooltip: 'Windmill can listen to WebSocket events and trigger scripts or flows based on them.',
|
||||
newLabel: 'WebSocket trigger',
|
||||
searchPlaceholder: 'Search WS triggers',
|
||||
filterLabel: 'WS trigger',
|
||||
empty: {
|
||||
title: 'No WebSocket triggers yet',
|
||||
description:
|
||||
'Windmill can listen to WebSocket events and trigger scripts or flows based on them.',
|
||||
actionLabel: 'Add a WebSocket trigger',
|
||||
aiId: 'websocket-triggers-empty-add',
|
||||
aiDescription: 'Add WebSocket trigger'
|
||||
},
|
||||
cloudDisabled: true,
|
||||
operatorGate: false,
|
||||
adminOnly: false,
|
||||
usedKind: 'websockets',
|
||||
deployKind: 'websockets',
|
||||
shareKind: 'websocket_trigger',
|
||||
draftKind: 'trigger_websocket',
|
||||
status: {
|
||||
starting: 'WebSocket is starting...',
|
||||
notConnected: 'WebSocket is not connected',
|
||||
disabled: 'WebSocket was disabled because of an error',
|
||||
connected: 'WebSocket is connected',
|
||||
shuttingDown: true,
|
||||
pings: true
|
||||
},
|
||||
forkConflictLabel: 'websocket trigger',
|
||||
modeToastLabel: 'websocket trigger',
|
||||
modeError: (verb) => `Cannot ${verb} websocket trigger`,
|
||||
catchDelete: false,
|
||||
list: (a) => WebsocketTriggerService.listWebsocketTriggers(a),
|
||||
setMode: (a) => WebsocketTriggerService.setWebsocketTriggerMode(a),
|
||||
remove: (a) => WebsocketTriggerService.deleteWebsocketTrigger(a)
|
||||
},
|
||||
postgres: {
|
||||
title: 'Postgres triggers',
|
||||
tooltip:
|
||||
'Windmill enables real-time responsiveness by listening to specific database transactions—such as inserts, updates, and deletes—and automatically triggering scripts or workflows in response.',
|
||||
newLabel: 'Postgres trigger',
|
||||
searchPlaceholder: 'Search Postgres triggers',
|
||||
filterLabel: 'Postgres trigger',
|
||||
empty: {
|
||||
title: 'No Postgres triggers yet',
|
||||
description:
|
||||
'Windmill can listen to database transactions — inserts, updates and deletes — and trigger scripts or flows in response.',
|
||||
actionLabel: 'Add a Postgres trigger',
|
||||
aiId: 'postgres-triggers-empty-add',
|
||||
aiDescription: 'Add Postgres trigger'
|
||||
},
|
||||
cloudDisabled: true,
|
||||
operatorGate: false,
|
||||
adminOnly: false,
|
||||
usedKind: 'postgres',
|
||||
deployKind: 'postgres',
|
||||
shareKind: 'postgres_trigger',
|
||||
draftKind: 'trigger_postgres',
|
||||
status: {
|
||||
starting: 'Postgres trigger is starting...',
|
||||
notConnected: 'Could not connect to database',
|
||||
disabled: 'Disabled because of an error',
|
||||
connected: 'Connected to database',
|
||||
shuttingDown: true,
|
||||
pings: false
|
||||
},
|
||||
forkConflictLabel: 'postgres trigger',
|
||||
modeToastLabel: 'postgres trigger',
|
||||
modeError: () => 'Cannot change postgres trigger mode',
|
||||
catchDelete: false,
|
||||
list: (a) => PostgresTriggerService.listPostgresTriggers(a),
|
||||
setMode: (a) => PostgresTriggerService.setPostgresTriggerMode(a),
|
||||
remove: (a) => PostgresTriggerService.deletePostgresTrigger(a)
|
||||
},
|
||||
kafka: {
|
||||
title: 'Kafka triggers',
|
||||
tooltip: 'Windmill can consume kafka events and trigger scripts or flows based on them.',
|
||||
newLabel: 'Kafka trigger',
|
||||
searchPlaceholder: 'Search Kafka triggers',
|
||||
filterLabel: 'Kafka trigger',
|
||||
empty: {
|
||||
title: 'No Kafka triggers yet',
|
||||
description: 'Windmill can consume kafka events and trigger scripts or flows based on them.',
|
||||
actionLabel: 'Add a Kafka trigger',
|
||||
aiId: 'kafka-triggers-empty-add',
|
||||
aiDescription: 'Add Kafka trigger'
|
||||
},
|
||||
cloudDisabled: true,
|
||||
operatorGate: true,
|
||||
adminOnly: false,
|
||||
usedKind: 'kafka',
|
||||
deployKind: 'kafka',
|
||||
shareKind: 'kafka_trigger',
|
||||
draftKind: 'trigger_kafka',
|
||||
status: consumerStatus,
|
||||
forkConflictLabel: 'Kafka trigger',
|
||||
modeError: (verb) => `Cannot ${verb} Kafka trigger`,
|
||||
catchDelete: false,
|
||||
list: (a) => KafkaTriggerService.listKafkaTriggers(a),
|
||||
setMode: (a) => KafkaTriggerService.setKafkaTriggerMode(a),
|
||||
remove: (a) => KafkaTriggerService.deleteKafkaTrigger(a)
|
||||
},
|
||||
nats: {
|
||||
title: 'NATS triggers',
|
||||
tooltip: 'Windmill can consume NATS events and trigger scripts or flows based on them.',
|
||||
newLabel: 'NATS trigger',
|
||||
searchPlaceholder: 'Search NATS triggers',
|
||||
filterLabel: 'NATS trigger',
|
||||
empty: {
|
||||
title: 'No NATS triggers yet',
|
||||
description: 'Windmill can consume NATS events and trigger scripts or flows based on them.',
|
||||
actionLabel: 'Add a NATS trigger',
|
||||
aiId: 'nats-triggers-empty-add',
|
||||
aiDescription: 'Add NATS trigger'
|
||||
},
|
||||
cloudDisabled: true,
|
||||
operatorGate: true,
|
||||
adminOnly: false,
|
||||
usedKind: 'nats',
|
||||
deployKind: 'nats',
|
||||
shareKind: 'nats_trigger',
|
||||
draftKind: 'trigger_nats',
|
||||
status: consumerStatus,
|
||||
forkConflictLabel: 'NATS trigger',
|
||||
modeError: (verb) => `Cannot ${verb} NATS trigger`,
|
||||
catchDelete: false,
|
||||
list: (a) => NatsTriggerService.listNatsTriggers(a),
|
||||
setMode: (a) => NatsTriggerService.setNatsTriggerMode(a),
|
||||
remove: (a) => NatsTriggerService.deleteNatsTrigger(a)
|
||||
},
|
||||
sqs: {
|
||||
title: 'SQS triggers',
|
||||
tooltip: 'SQS trigger',
|
||||
newLabel: 'SQS trigger',
|
||||
searchPlaceholder: 'Search SQS triggers',
|
||||
filterLabel: 'SQS trigger',
|
||||
empty: {
|
||||
title: 'No SQS triggers yet',
|
||||
description:
|
||||
'Windmill can consume messages from an SQS queue and trigger scripts or flows on each one.',
|
||||
actionLabel: 'Add an SQS trigger',
|
||||
aiId: 'sqs-triggers-empty-add',
|
||||
aiDescription: 'Add SQS trigger'
|
||||
},
|
||||
cloudDisabled: true,
|
||||
operatorGate: false,
|
||||
adminOnly: false,
|
||||
usedKind: 'sqs',
|
||||
deployKind: 'sqs',
|
||||
shareKind: 'sqs_trigger',
|
||||
draftKind: 'trigger_sqs',
|
||||
status: {
|
||||
starting: 'SQS trigger is starting...',
|
||||
notConnected: 'Could not connect to SQS',
|
||||
disabled: 'Disabled because of an error',
|
||||
connected: 'Connected to SQS',
|
||||
shuttingDown: true,
|
||||
pings: false
|
||||
},
|
||||
forkConflictLabel: 'SQS trigger',
|
||||
modeError: (verb) => `Cannot ${verb} sqs trigger`,
|
||||
catchDelete: true,
|
||||
list: (a) => SqsTriggerService.listSqsTriggers(a),
|
||||
setMode: (a) => SqsTriggerService.setSqsTriggerMode(a),
|
||||
remove: (a) => SqsTriggerService.deleteSqsTrigger(a)
|
||||
},
|
||||
gcp: {
|
||||
title: 'GCP Pub/Sub triggers',
|
||||
tooltip: 'GCP Pub/Sub trigger',
|
||||
newLabel: 'GCP Pub/Sub trigger',
|
||||
searchPlaceholder: 'Search triggers',
|
||||
filterLabel: 'GCP Pub/Sub trigger',
|
||||
empty: {
|
||||
title: 'No GCP Pub/Sub triggers yet',
|
||||
description:
|
||||
'Windmill can subscribe to a GCP Pub/Sub topic and trigger scripts or flows on each message.',
|
||||
actionLabel: 'Add a GCP Pub/Sub trigger',
|
||||
aiId: 'gcp-triggers-empty-add',
|
||||
aiDescription: 'Add GCP Pub/Sub trigger'
|
||||
},
|
||||
cloudDisabled: true,
|
||||
operatorGate: false,
|
||||
adminOnly: false,
|
||||
usedKind: 'gcp',
|
||||
deployKind: 'gcp',
|
||||
shareKind: 'gcp_trigger',
|
||||
draftKind: 'trigger_gcp',
|
||||
status: {
|
||||
starting: 'GCP Pub/Sub trigger is starting...',
|
||||
notConnected: 'Could not connect to GCP Pub/Sub',
|
||||
disabled: 'Disabled because of an error',
|
||||
connected: 'Connected to GCP Pub/Sub',
|
||||
shuttingDown: true,
|
||||
pings: false
|
||||
},
|
||||
forkConflictLabel: 'GCP Pub/Sub trigger',
|
||||
modeError: (verb) => `Cannot ${verb} GCP Pub/Sub trigger`,
|
||||
catchDelete: false,
|
||||
list: (a) => GcpTriggerService.listGcpTriggers(a),
|
||||
setMode: (a) => GcpTriggerService.setGcpTriggerMode(a),
|
||||
remove: (a) => GcpTriggerService.deleteGcpTrigger(a)
|
||||
},
|
||||
azure: {
|
||||
title: 'Azure Event Grid triggers',
|
||||
tooltip: 'Azure Event Grid trigger',
|
||||
newLabel: 'Azure Event Grid trigger',
|
||||
searchPlaceholder: 'Search triggers',
|
||||
filterLabel: 'Azure Event Grid trigger',
|
||||
empty: {
|
||||
title: 'No Azure Event Grid triggers yet',
|
||||
description:
|
||||
'Windmill can subscribe to an Azure Event Grid topic and trigger scripts or flows on each event.',
|
||||
actionLabel: 'Add an Azure Event Grid trigger',
|
||||
aiId: 'azure-triggers-empty-add',
|
||||
aiDescription: 'Add Azure Event Grid trigger'
|
||||
},
|
||||
cloudDisabled: true,
|
||||
operatorGate: false,
|
||||
adminOnly: false,
|
||||
usedKind: 'azure',
|
||||
deployKind: 'azure',
|
||||
shareKind: 'azure_trigger',
|
||||
draftKind: 'trigger_azure',
|
||||
status: {
|
||||
starting: 'Azure Event Grid trigger is starting...',
|
||||
notConnected: 'Could not connect to Azure Event Grid',
|
||||
disabled: 'Disabled because of an error',
|
||||
connected: 'Connected to Azure Event Grid',
|
||||
shuttingDown: true,
|
||||
pings: false
|
||||
},
|
||||
forkConflictLabel: 'Azure trigger',
|
||||
modeError: (verb) => `Cannot ${verb} Azure Event Grid trigger`,
|
||||
catchDelete: false,
|
||||
list: (a) => AzureTriggerService.listAzureTriggers(a),
|
||||
setMode: (a) => AzureTriggerService.setAzureTriggerMode(a),
|
||||
remove: (a) => AzureTriggerService.deleteAzureTrigger(a)
|
||||
},
|
||||
mqtt: {
|
||||
title: 'MQTT triggers',
|
||||
tooltip: brokerTooltip('MQTT'),
|
||||
newLabel: 'MQTT trigger',
|
||||
searchPlaceholder: 'Search MQTT triggers',
|
||||
filterLabel: 'MQTT trigger',
|
||||
empty: {
|
||||
title: 'No MQTT triggers yet',
|
||||
description: brokerTooltip('MQTT'),
|
||||
actionLabel: 'Add an MQTT trigger',
|
||||
aiId: 'mqtt-triggers-empty-add',
|
||||
aiDescription: 'Add MQTT trigger'
|
||||
},
|
||||
cloudDisabled: true,
|
||||
operatorGate: false,
|
||||
adminOnly: false,
|
||||
usedKind: 'mqtt',
|
||||
deployKind: 'mqtt',
|
||||
shareKind: 'mqtt_trigger',
|
||||
draftKind: 'trigger_mqtt',
|
||||
status: {
|
||||
starting: 'MQTT trigger is starting...',
|
||||
notConnected: 'MQTT trigger is not connected',
|
||||
disabled: 'MQTT trigger was disabled because of an error',
|
||||
connected: 'MQTT trigger is connected',
|
||||
shuttingDown: true,
|
||||
pings: true
|
||||
},
|
||||
forkConflictLabel: 'MQTT trigger',
|
||||
modeToastLabel: 'MQTT trigger',
|
||||
modeError: (verb) => `Cannot ${verb} mqtt trigger`,
|
||||
catchDelete: false,
|
||||
list: (a) => MqttTriggerService.listMqttTriggers(a),
|
||||
setMode: (a) => MqttTriggerService.setMqttTriggerMode(a),
|
||||
remove: (a) => MqttTriggerService.deleteMqttTrigger(a)
|
||||
},
|
||||
amqp: {
|
||||
title: 'AMQP triggers',
|
||||
tooltip: brokerTooltip('AMQP'),
|
||||
newLabel: 'AMQP trigger',
|
||||
searchPlaceholder: 'Search AMQP triggers',
|
||||
filterLabel: 'AMQP trigger',
|
||||
empty: {
|
||||
title: 'No AMQP triggers yet',
|
||||
description: brokerTooltip('AMQP'),
|
||||
actionLabel: 'Add an AMQP trigger',
|
||||
aiId: 'amqp-triggers-empty-add',
|
||||
aiDescription: 'Add AMQP trigger'
|
||||
},
|
||||
cloudDisabled: true,
|
||||
operatorGate: false,
|
||||
adminOnly: false,
|
||||
usedKind: 'amqp',
|
||||
deployKind: 'amqp',
|
||||
shareKind: 'amqp_trigger',
|
||||
draftKind: 'trigger_amqp',
|
||||
status: {
|
||||
starting: 'AMQP trigger is starting...',
|
||||
notConnected: 'AMQP trigger is not connected',
|
||||
disabled: 'AMQP trigger was disabled because of an error',
|
||||
connected: 'AMQP trigger is connected',
|
||||
shuttingDown: true,
|
||||
pings: true
|
||||
},
|
||||
forkConflictLabel: 'AMQP trigger',
|
||||
modeToastLabel: 'AMQP trigger',
|
||||
modeError: (verb) => `Cannot ${verb} amqp trigger`,
|
||||
catchDelete: false,
|
||||
list: (a) => AmqpTriggerService.listAmqpTriggers(a),
|
||||
setMode: (a) => AmqpTriggerService.setAmqpTriggerMode(a),
|
||||
remove: (a) => AmqpTriggerService.deleteAmqpTrigger(a)
|
||||
},
|
||||
email: {
|
||||
title: 'Custom email triggers',
|
||||
tooltip:
|
||||
'Every script and flow already has a canonical email trigger attached to it, this is to create additional parametrizable ones.',
|
||||
documentationLink: 'https://www.windmill.dev/docs/advanced/email_triggers',
|
||||
newLabel: 'email trigger',
|
||||
searchPlaceholder: 'Search email triggers',
|
||||
filterLabel: 'Email',
|
||||
empty: {
|
||||
title: 'No custom email triggers yet',
|
||||
description:
|
||||
'Every script and flow already has a canonical email trigger attached to it. These are additional parametrizable ones.',
|
||||
actionLabel: 'Add an email trigger',
|
||||
aiId: 'email-triggers-empty-add',
|
||||
aiDescription: 'Add email trigger'
|
||||
},
|
||||
cloudDisabled: false,
|
||||
operatorGate: true,
|
||||
adminOnly: true,
|
||||
usedKind: 'emails',
|
||||
deployKind: 'routes',
|
||||
shareKind: 'email_trigger',
|
||||
draftKind: 'trigger_email',
|
||||
modeError: (verb) => `Cannot ${verb} email trigger`,
|
||||
deleteToastLabel: 'Email trigger',
|
||||
catchDelete: true,
|
||||
list: (a) => EmailTriggerService.listEmailTriggers(a),
|
||||
setMode: (a) => EmailTriggerService.setEmailTriggerMode(a),
|
||||
remove: (a) => EmailTriggerService.deleteEmailTrigger(a)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import {
|
||||
clearPageDrawerAnchor,
|
||||
handOffPageDrawer,
|
||||
setPageDrawerAnchor
|
||||
} from '$lib/components/sessions/pageDrawerSession'
|
||||
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
|
||||
@@ -202,7 +201,6 @@
|
||||
isFlow: boolean,
|
||||
defaultConfig?: Record<string, any>
|
||||
) {
|
||||
if (handOffPageDrawer(TRIGGER_PAGES.websocket.path, ePath)) return
|
||||
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
|
||||
operatingUser.forgetFailures()
|
||||
let loadingTimeout = setTimeout(() => {
|
||||
|
||||
@@ -0,0 +1,759 @@
|
||||
<script lang="ts">
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
Tab,
|
||||
TabFade,
|
||||
Tabs
|
||||
} from '$lib/components/common'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { OauthService, VariableService, WorkspaceService } from '$lib/gen'
|
||||
import ContextualVariableEditor from '$lib/components/ContextualVariableEditor.svelte'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import FilterSearchbar, {
|
||||
hasActiveFilters,
|
||||
useUrlSyncedFilterInstance
|
||||
} from '$lib/components/FilterSearchbar.svelte'
|
||||
import { buildVariablesFilterSchema } from '$lib/components/variables/variablesFilter'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import Cell from '$lib/components/table/Cell.svelte'
|
||||
import DataTable from '$lib/components/table/DataTable.svelte'
|
||||
import Head from '$lib/components/table/Head.svelte'
|
||||
import Row from '$lib/components/table/Row.svelte'
|
||||
import TableSimple from '$lib/components/TableSimple.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import VariableEditor from '$lib/components/VariableEditor.svelte'
|
||||
import type { ContextualVariable, ListableVariable, WorkspaceDeployUISettings } from '$lib/gen'
|
||||
import { enterpriseLicense, userWorkspaces } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { canWrite, isOwner, truncate } from '$lib/utils'
|
||||
import { isDeployable, ALL_DEPLOYABLE } from '$lib/utils_deployable'
|
||||
import {
|
||||
Plus,
|
||||
FileUp,
|
||||
Link,
|
||||
Pen,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Trash,
|
||||
Building,
|
||||
DollarSign,
|
||||
EyeOff,
|
||||
Circle,
|
||||
SearchX
|
||||
} from 'lucide-svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { page } from '$app/stores'
|
||||
import { useHostedPage } from '$lib/components/hostedPage'
|
||||
import {
|
||||
useOperatingWorkspace,
|
||||
useOperatingUser
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const operatingUser = useOperatingUser()
|
||||
const hosted = useHostedPage()
|
||||
|
||||
function editVariable(path: string) {
|
||||
if (hosted) hosted.openItem(path)
|
||||
else variableEditor?.editVariable(path)
|
||||
}
|
||||
|
||||
type ListableVariableW = ListableVariable & { canWrite: boolean }
|
||||
|
||||
let variables = $state(undefined) as ListableVariableW[] | undefined
|
||||
let showCreateButtons = $state(false)
|
||||
|
||||
// Collect unique values for filter autocomplete
|
||||
let allPaths: string[] = $state([])
|
||||
let allOwners: string[] = $state([])
|
||||
let allLabels: string[] = $state([])
|
||||
|
||||
// FilterSearchbar setup
|
||||
let userFoldersFilterType = $derived(
|
||||
operatingUser.current?.is_super_admin && operatingUser.current.username.includes('@')
|
||||
? 'only f/*'
|
||||
: operatingUser.current?.is_admin || operatingUser.current?.is_super_admin
|
||||
? 'u/username and f/*'
|
||||
: undefined
|
||||
)
|
||||
let variablesFilterSchema = $derived(
|
||||
buildVariablesFilterSchema({
|
||||
paths: allPaths,
|
||||
owners: allOwners,
|
||||
labels: allLabels,
|
||||
showUserFoldersFilter: userFoldersFilterType !== undefined,
|
||||
userFoldersLabel:
|
||||
userFoldersFilterType === 'only f/*'
|
||||
? 'Only f/*'
|
||||
: `Only u/${operatingUser.current?.username} and f/*`
|
||||
})
|
||||
)
|
||||
let filters = useUrlSyncedFilterInstance(untrack(() => variablesFilterSchema))
|
||||
// A link can carry the user folders filter to someone it is not offered to, where it would sit
|
||||
// set with no chip to clear it. Dropped only once a user is actually known: `resolved` holds for
|
||||
// the navigation workspace even while its user is still loading.
|
||||
$effect(() => {
|
||||
if (!operatingUser.resolved($operatingWorkspace) || !operatingUser.current) return
|
||||
if (userFoldersFilterType !== undefined) return
|
||||
if (!filters.val.user_folders_only) return
|
||||
untrack(() => {
|
||||
const { user_folders_only: _, ...rest } = filters.val
|
||||
filters.val = rest
|
||||
})
|
||||
})
|
||||
let itemFolders = $derived(
|
||||
Array.from(
|
||||
new Set(
|
||||
(variables ?? [])
|
||||
.map((x) => x.path.split('/').slice(0, 2).join('/'))
|
||||
.filter((x) => x.startsWith('f/'))
|
||||
)
|
||||
)
|
||||
.sort()
|
||||
.map((f) => f.replace(/^f\//, ''))
|
||||
)
|
||||
let folderPresets = $derived([
|
||||
...itemFolders.map((f) => ({ name: `f/${f}`, value: `path_start:\\ f/${f}/` })),
|
||||
...allLabels.map((l) => ({ name: l, value: `label:\\ ${l}` })),
|
||||
...(!variablesFilterSchema.user_folders_only.hidden
|
||||
? [
|
||||
{
|
||||
name: variablesFilterSchema.user_folders_only.label ?? '?',
|
||||
value: 'user_folders_only:\\ true'
|
||||
}
|
||||
]
|
||||
: [])
|
||||
])
|
||||
let contextualVariables: ContextualVariable[] = $state([])
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
let variableEditor: VariableEditor | undefined = $state()
|
||||
let contextualVariableEditor: ContextualVariableEditor | undefined = $state()
|
||||
let loading = $state({
|
||||
contextual: true
|
||||
})
|
||||
|
||||
let deleteConfirmedCallback: (() => void) | undefined = $state(undefined)
|
||||
let deleteIsLinked = $state(false)
|
||||
let open = $derived(Boolean(deleteConfirmedCallback))
|
||||
|
||||
// Filter variables client-side for user folder filtering (admin feature)
|
||||
let filteredItems = $derived.by(() => {
|
||||
let items = variables
|
||||
if (filters.val.user_folders_only && items) {
|
||||
items = items.filter((item) => {
|
||||
if (userFoldersFilterType === 'only f/*') return item.path.startsWith('f/')
|
||||
if (userFoldersFilterType === 'u/username and f/*')
|
||||
return (
|
||||
item.path.startsWith('f/') ||
|
||||
item.path.startsWith(`u/${operatingUser.current?.username}/`)
|
||||
)
|
||||
return true
|
||||
})
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
// If relative, the dropdown is positioned relative to its button
|
||||
async function loadVariables(): Promise<void> {
|
||||
const currentFilters = filters.val
|
||||
|
||||
// Build API parameters from filters.
|
||||
// `includeDraftOnly` surfaces per-user drafts at paths that have
|
||||
// no deployed variable — appended server-side when no narrowing
|
||||
// filter is set, so the user sees AI-agent-created drafts on
|
||||
// the home page.
|
||||
const apiParams: any = {
|
||||
workspace: $operatingWorkspace!,
|
||||
includeDraftOnly: true
|
||||
}
|
||||
|
||||
if (currentFilters.path) {
|
||||
apiParams.path = currentFilters.path
|
||||
}
|
||||
if (currentFilters.path_start) {
|
||||
apiParams.pathStart = currentFilters.path_start
|
||||
}
|
||||
if (currentFilters.description) {
|
||||
apiParams.description = currentFilters.description
|
||||
}
|
||||
if (currentFilters.value) {
|
||||
apiParams.value = currentFilters.value
|
||||
}
|
||||
if (currentFilters.owner) {
|
||||
apiParams.pathStart = currentFilters.owner
|
||||
}
|
||||
if (currentFilters._default_) {
|
||||
apiParams.broadFilter = currentFilters._default_
|
||||
}
|
||||
if (currentFilters.label) {
|
||||
apiParams.label = currentFilters.label
|
||||
}
|
||||
|
||||
const result = (await VariableService.listVariable(apiParams)).map((x) => {
|
||||
return {
|
||||
canWrite:
|
||||
canWrite(x.path, x.extra_perms!, operatingUser.current) &&
|
||||
x.workspace_id == $operatingWorkspace,
|
||||
...x
|
||||
}
|
||||
})
|
||||
|
||||
// Extract unique values for autocomplete
|
||||
allPaths = Array.from(new Set(result.map((x) => x.path))).sort()
|
||||
allOwners = Array.from(
|
||||
new Set(result.map((x) => x.path.split('/').slice(0, 2).join('/')))
|
||||
).sort()
|
||||
allLabels = Array.from(
|
||||
new Set(result.flatMap((x) => [...(x.labels ?? []), ...(x.inherited_labels ?? [])]))
|
||||
).sort()
|
||||
|
||||
variables = result
|
||||
}
|
||||
|
||||
// Reload variables when filters change
|
||||
$effect(() => {
|
||||
filters.val
|
||||
if ($operatingWorkspace) {
|
||||
untrack(() => loadVariables())
|
||||
}
|
||||
})
|
||||
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $operatingWorkspace! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
async function loadContextualVariables(): Promise<void> {
|
||||
contextualVariables = await VariableService.listContextualVariables({
|
||||
workspace: $operatingWorkspace!
|
||||
})
|
||||
loading.contextual = false
|
||||
}
|
||||
|
||||
async function deleteVariable(path: string, account?: number): Promise<void> {
|
||||
if (account) {
|
||||
OauthService.disconnectAccount({ workspace: $operatingWorkspace!, id: account })
|
||||
}
|
||||
await VariableService.deleteVariable({ workspace: $operatingWorkspace!, path })
|
||||
loadVariables()
|
||||
sendUserToast(`Variable ${path} was deleted`)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($operatingWorkspace && operatingUser.current) {
|
||||
untrack(() => {
|
||||
loadVariables()
|
||||
loadContextualVariables()
|
||||
})
|
||||
}
|
||||
})
|
||||
let tab: 'workspace' | 'contextual' = $state('workspace')
|
||||
|
||||
let activeFilters = $derived(hasActiveFilters(filters.val))
|
||||
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
|
||||
async function deleteContextualVariable(row: { name: string }) {
|
||||
await WorkspaceService.setEnvironmentVariable({
|
||||
workspace: $operatingWorkspace!,
|
||||
requestBody: {
|
||||
name: row.name,
|
||||
value: undefined
|
||||
}
|
||||
})
|
||||
loadContextualVariables()
|
||||
sendUserToast(
|
||||
`Custom contextual variable ${row.name} was deleted. It may take up to a few minutes to update.`
|
||||
)
|
||||
setTimeout(() => {
|
||||
loadContextualVariables()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
// Deep link: #<path> opens that variable's edit drawer. Reactive rather than
|
||||
// onMount so a hash change on the already-mounted page (e.g. the AI session
|
||||
// preview re-pointing its tab) opens the drawer too. Row links pre-set
|
||||
// handledHash: their onclick already opens the drawer.
|
||||
let handledHash = ''
|
||||
$effect(() => {
|
||||
// A host opens rows itself, and the document's hash is not this page's.
|
||||
if (hosted) return
|
||||
const hash = $page.url.hash
|
||||
// Only item paths are drawer targets: the same hash also carries global
|
||||
// drawers like #superadmin-settings, which must not be looked up as a variable.
|
||||
if (!/^#[ufg]\//.test(hash)) {
|
||||
// Navigating away from a drawer target must clear the tracker, or
|
||||
// re-targeting the same item later would be skipped as already handled.
|
||||
handledHash = ''
|
||||
return
|
||||
}
|
||||
if (hash === handledHash || !variableEditor) return
|
||||
handledHash = hash
|
||||
variableEditor.editVariable(hash.slice(1))
|
||||
})
|
||||
</script>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
|
||||
{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.variables}
|
||||
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
|
||||
<p class="font-bold">Unauthorized</p>
|
||||
<p>Page not available for operators</p>
|
||||
</div>
|
||||
{:else}
|
||||
<CenteredPage>
|
||||
<PageHeader
|
||||
title="Variables"
|
||||
tooltip="Save and permission strings to be reused in Scripts and Flows."
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets"
|
||||
>
|
||||
{#if showCreateButtons}
|
||||
<div class="flex flex-row justify-end">
|
||||
{#if tab == 'contextual' && (operatingUser.current?.is_admin || operatingUser.current?.is_super_admin)}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => contextualVariableEditor?.initNew()}
|
||||
>
|
||||
New contextual variable
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => variableEditor?.initNew()}
|
||||
>
|
||||
New variable
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</PageHeader>
|
||||
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
|
||||
|
||||
<VariableEditor
|
||||
bind:this={variableEditor}
|
||||
workspace={$operatingWorkspace}
|
||||
on:create={loadVariables}
|
||||
/>
|
||||
<ContextualVariableEditor
|
||||
bind:this={contextualVariableEditor}
|
||||
on:update={loadContextualVariables}
|
||||
/>
|
||||
<ShareModal
|
||||
bind:this={shareModal}
|
||||
on:change={() => {
|
||||
loadVariables()
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="flex gap-2 justify-between items-center">
|
||||
<Tabs bind:selected={tab}>
|
||||
<Tab value="workspace" label="Workspace" icon={Building} />
|
||||
<Tab value="contextual" label="Contextual" icon={DollarSign}>
|
||||
{#snippet extra()}
|
||||
<Tooltip
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets#contextual-variables"
|
||||
>
|
||||
Contextual variables are passed as environment variables when running a script and
|
||||
depends on the execution context.
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
{#if tab == 'workspace'}
|
||||
<FilterSearchbar
|
||||
class="grow max-w-[26rem]"
|
||||
schema={variablesFilterSchema}
|
||||
bind:value={filters.val}
|
||||
placeholder="Filter variables..."
|
||||
presets={folderPresets}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<TabFade key={tab}>
|
||||
{#if tab == 'workspace'}
|
||||
<div class="relative overflow-x-auto pb-40 mt-4">
|
||||
{#if !filteredItems}
|
||||
<Skeleton layout={[0.5, [2], 1]} />
|
||||
{#each new Array(3) as _}
|
||||
<Skeleton layout={[[3.5], 0.5]} />
|
||||
{/each}
|
||||
{:else if filteredItems.length == 0}
|
||||
{#if activeFilters}
|
||||
<EmptyState
|
||||
icon={SearchX}
|
||||
title="No variables found"
|
||||
description="No variable matches the current filters. Try clearing or widening them."
|
||||
/>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={DollarSign}
|
||||
title="No variables yet"
|
||||
description="Variables store values and secrets that your scripts, flows and apps can read at runtime."
|
||||
action={showCreateButtons
|
||||
? {
|
||||
label: 'Add a variable',
|
||||
icon: Plus,
|
||||
onClick: () => variableEditor?.initNew(),
|
||||
aiId: 'variables-empty-add-variable',
|
||||
aiDescription: 'Add variable'
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<DataTable size="xs">
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first class="!px-0" />
|
||||
<Cell head>Path</Cell>
|
||||
<Cell head>Value</Cell>
|
||||
<Cell head>Description</Cell>
|
||||
<Cell head>Status</Cell>
|
||||
<Cell head last actions>Actions</Cell>
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y">
|
||||
{#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft }}
|
||||
{@const hasDraft =
|
||||
getLocalDraftHint($operatingWorkspace, 'variable', path) ?? is_draft}
|
||||
<Row>
|
||||
<Cell class="!px-0 text-center w-12" first>
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
</Cell>
|
||||
<Cell>
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
class="break-all"
|
||||
id="edit-{path}"
|
||||
onclick={() => {
|
||||
handledHash = `#${path}`
|
||||
editVariable(path)
|
||||
}}
|
||||
href="#{path}"
|
||||
>
|
||||
{path}{hasDraft ? '*' : ''}
|
||||
</a>
|
||||
<DraftBadge {draft_only} is_draft={hasDraft} />
|
||||
{#if labels?.length}
|
||||
{#each labels as label}
|
||||
<Badge
|
||||
color="blue"
|
||||
small
|
||||
class="px-1"
|
||||
title="Label: {label}"
|
||||
clickable
|
||||
onclick={() => {
|
||||
const arr = (filters.val.label ?? '').split(',').filter(Boolean)
|
||||
const idx = arr.indexOf(label)
|
||||
if (idx >= 0) arr.splice(idx, 1)
|
||||
else arr.push(label)
|
||||
const newFilters = { ...filters.val }
|
||||
if (arr.length) newFilters.label = arr.join(',')
|
||||
else delete newFilters.label
|
||||
filters.val = newFilters
|
||||
}}>{label}</Badge
|
||||
>
|
||||
{/each}
|
||||
{/if}
|
||||
<InheritedLabels labels={inherited_labels} />
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<span class="inline-flex flex-row items-center gap-2">
|
||||
<div class="text-xs break-words">
|
||||
{#if value}
|
||||
{truncate(value, 20)}
|
||||
{:else}
|
||||
∗∗∗∗
|
||||
{/if}
|
||||
</div>
|
||||
{#if is_secret}
|
||||
<Popover notClickable>
|
||||
<EyeOff size={12} />
|
||||
{#snippet text()}
|
||||
<span>This item is secret</span>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</span>
|
||||
</Cell>
|
||||
<Cell class="break-words">
|
||||
<span class="text-xs text-primary">{truncate(description ?? '', 50)} </span>
|
||||
</Cell>
|
||||
|
||||
<Cell class="text-center">
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
{#if is_linked}
|
||||
<Popover notClickable>
|
||||
<Link size={16} />
|
||||
{#snippet text()}
|
||||
<div>
|
||||
This variable is linked with a resource of the same path. They are
|
||||
deleted and renamed together.
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if account}
|
||||
<Popover notClickable>
|
||||
<RefreshCw size={16} />
|
||||
{#snippet text()}
|
||||
<div>
|
||||
This OAuth token will be kept up-to-date in the background by
|
||||
Windmill using its refresh token
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
|
||||
{#if is_refreshed}
|
||||
<div class="">
|
||||
{#if refresh_error}
|
||||
<Popover notClickable>
|
||||
<!-- isolate: confine the ping indicator's z-50 to a local stacking context
|
||||
so it can't paint over anything that scrolls past it -->
|
||||
<div
|
||||
class="relative inline-flex justify-center items-center w-4 h-4 isolate"
|
||||
>
|
||||
<Circle
|
||||
class="text-red-600 animate-ping absolute z-50 w-4 h-4 fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<Circle
|
||||
class="text-red-600 relative inline-flex fill-current "
|
||||
size={12}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#snippet text()}
|
||||
<div>
|
||||
Latest exchange of the refresh token did not succeed. Error: {refresh_error}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else if is_expired}
|
||||
<Popover notClickable>
|
||||
<Circle
|
||||
class="text-yellow-600 animate-[pulse_5s_linear_infinite] fill-current"
|
||||
size={12}
|
||||
/>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
The access_token is expired, it will get renewed the next time
|
||||
this variable is fetched or you can request is to be refreshed
|
||||
in the dropdown on the right.
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else}
|
||||
<Popover notClickable>
|
||||
<Circle
|
||||
class="text-green-600 animate-[pulse_5s_linear_infinite] fill-current"
|
||||
size={12}
|
||||
/>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
The variable was connected through OAuth and the token is not
|
||||
expired.
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell last actions shouldStopPropagation>
|
||||
<Dropdown
|
||||
items={() => {
|
||||
let owner = isOwner(path, operatingUser.current, $operatingWorkspace)
|
||||
return [
|
||||
{
|
||||
displayName: 'Edit',
|
||||
icon: Pen,
|
||||
action: () => editVariable(path),
|
||||
disabled: !canWrite || !showCreateButtons
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
type: 'delete',
|
||||
action: (event) => {
|
||||
if (event['shiftKey']) {
|
||||
deleteVariable(path, account)
|
||||
} else {
|
||||
deleteIsLinked = is_linked ?? false
|
||||
deleteConfirmedCallback = () => {
|
||||
deleteVariable(path, account)
|
||||
}
|
||||
}
|
||||
},
|
||||
disabled: !owner || !showCreateButtons
|
||||
},
|
||||
...(!ws_specific &&
|
||||
isDeployable(is_secret ? 'secret' : 'variable', path, deployUiSettings)
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer?.openDrawer(path, 'variable')
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
action: () => {
|
||||
shareModal?.openDrawer(path, 'variable')
|
||||
},
|
||||
icon: Shield
|
||||
},
|
||||
...(account != undefined
|
||||
? [
|
||||
{
|
||||
displayName: 'Refresh token',
|
||||
icon: RefreshCw,
|
||||
action: async () => {
|
||||
await OauthService.refreshToken({
|
||||
workspace: $operatingWorkspace ?? '',
|
||||
id: account ?? 0,
|
||||
requestBody: {
|
||||
path
|
||||
}
|
||||
})
|
||||
sendUserToast('Token refreshed')
|
||||
loadVariables()
|
||||
}
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}}
|
||||
/>
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if tab == 'contextual'}
|
||||
<div class="overflow-auto">
|
||||
{#if loading.contextual}
|
||||
<Skeleton layout={[0.5, [2], 1]} />
|
||||
{#each new Array(8) as _}
|
||||
<Skeleton layout={[[2.8], 0.5]} />
|
||||
{/each}
|
||||
{:else}
|
||||
<PageHeader title="Custom contextual variables" primary={false} />
|
||||
{#if contextualVariables.filter((x) => x.is_custom).length === 0}
|
||||
<EmptyState
|
||||
icon={DollarSign}
|
||||
title="No custom contextual variables yet"
|
||||
description="Custom contextual variables are passed as environment variables to every script run in this workspace."
|
||||
action={showCreateButtons &&
|
||||
(operatingUser.current?.is_admin || operatingUser.current?.is_super_admin)
|
||||
? {
|
||||
label: 'Add a contextual variable',
|
||||
icon: Plus,
|
||||
onClick: () => contextualVariableEditor?.initNew(),
|
||||
aiId: 'variables-empty-add-contextual-variable',
|
||||
aiDescription: 'Add contextual variable'
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
{:else}
|
||||
<TableSimple
|
||||
headers={['Name', 'Value']}
|
||||
data={contextualVariables.filter((x) => x.is_custom)}
|
||||
keys={['name', 'value']}
|
||||
getRowActions={operatingUser.current?.is_admin ||
|
||||
operatingUser.current?.is_super_admin
|
||||
? (row) => {
|
||||
return [
|
||||
{
|
||||
displayName: 'Edit',
|
||||
action: () => contextualVariableEditor?.editVariable(row.name, row.value)
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
type: 'delete',
|
||||
action: () => {
|
||||
deleteContextualVariable(row)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
{/if}
|
||||
<PageHeader title="Contextual variables" primary={false} />
|
||||
<TableSimple
|
||||
headers={['Name', 'Example of value', 'Description']}
|
||||
data={contextualVariables.filter((x) => !x.is_custom)}
|
||||
keys={['name', 'value', 'description']}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</TabFade>
|
||||
</CenteredPage>
|
||||
{/if}
|
||||
|
||||
<ConfirmationModal
|
||||
{open}
|
||||
title="Remove variable"
|
||||
confirmationText="Remove"
|
||||
trashbin
|
||||
on:canceled={() => {
|
||||
deleteConfirmedCallback = undefined
|
||||
}}
|
||||
on:confirmed={() => {
|
||||
if (deleteConfirmedCallback) {
|
||||
deleteConfirmedCallback()
|
||||
}
|
||||
deleteConfirmedCallback = undefined
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
<span>Are you sure you want to remove this variable?</span>
|
||||
{#if deleteIsLinked}
|
||||
<Alert type="warning" title="Linked resource">
|
||||
This variable is linked with a resource of the same path. The linked resource will also be
|
||||
deleted.
|
||||
</Alert>
|
||||
{/if}
|
||||
<Alert type="info" title="Bypass confirmation">
|
||||
<div>
|
||||
You can press
|
||||
<Badge color="dark-gray">SHIFT</Badge>
|
||||
while removing a variable to bypass confirmation.
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
@@ -64,15 +64,14 @@ export function buildVariablesFilterSchema({
|
||||
icon: Tag,
|
||||
description: 'Filter by label (comma-separated for multiple)'
|
||||
},
|
||||
...(showUserFoldersFilter
|
||||
? {
|
||||
user_folders_only: {
|
||||
type: 'boolean' as const,
|
||||
label: userFoldersLabel || 'User folders only',
|
||||
icon: Users,
|
||||
description: 'Show only variables in user folders'
|
||||
}
|
||||
}
|
||||
: {})
|
||||
// The key stays for every role, hidden where the filter does not apply: the list's URL sync
|
||||
// reads the key set once, before the acting user that gates it may have loaded.
|
||||
user_folders_only: {
|
||||
type: 'boolean' as const,
|
||||
label: userFoldersLabel || 'User folders only',
|
||||
icon: Users,
|
||||
description: 'Show only variables in user folders',
|
||||
hidden: !showUserFoldersFilter
|
||||
}
|
||||
} satisfies FilterSchemaRec
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
import { z } from 'zod'
|
||||
import { serializeParam } from '$lib/utils/serializeParam'
|
||||
import { untrack } from 'svelte'
|
||||
import { useHostedPage } from '$lib/components/hostedPage'
|
||||
|
||||
export type SearchParamsResult<S extends z.ZodType> =
|
||||
S extends z.ZodObject<infer Shape>
|
||||
@@ -58,12 +60,14 @@ function deserializeParam(raw: string, fieldSchema: z.ZodType): unknown {
|
||||
export function useSearchParams<S extends z.ZodType>(schema: S): SearchParamsResult<S> {
|
||||
const shape: Record<string, z.ZodType> = (schema as any).shape ?? {}
|
||||
const keys = Object.keys(shape)
|
||||
const hosted = useHostedPage()
|
||||
const currentSearch = () => (hosted ? hosted.search : window.location.search)
|
||||
|
||||
// Reactive snapshot of search params - one $state cell per key
|
||||
const values: Record<string, unknown> = $state(
|
||||
Object.fromEntries(
|
||||
keys.map((k) => {
|
||||
const raw = new URLSearchParams(window.location.search).get(k)
|
||||
const raw = new URLSearchParams(untrack(currentSearch)).get(k)
|
||||
const parsed = raw != null ? deserializeParam(raw, shape[k]) : null
|
||||
return [k, parsed]
|
||||
})
|
||||
@@ -71,7 +75,7 @@ export function useSearchParams<S extends z.ZodType>(schema: S): SearchParamsRes
|
||||
)
|
||||
|
||||
function syncFromUrl() {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const sp = new URLSearchParams(currentSearch())
|
||||
for (const k of keys) {
|
||||
const raw = sp.get(k)
|
||||
const parsed = raw != null ? deserializeParam(raw, shape[k]) : null
|
||||
@@ -79,8 +83,13 @@ export function useSearchParams<S extends z.ZodType>(schema: S): SearchParamsRes
|
||||
}
|
||||
}
|
||||
|
||||
// Keep in sync when the user navigates back/forward
|
||||
// Keep in sync when the user navigates back/forward, or the host re-points the page
|
||||
$effect(() => {
|
||||
if (hosted) {
|
||||
hosted.search
|
||||
untrack(syncFromUrl)
|
||||
return
|
||||
}
|
||||
window.addEventListener('popstate', syncFromUrl)
|
||||
return () => window.removeEventListener('popstate', syncFromUrl)
|
||||
})
|
||||
@@ -94,12 +103,16 @@ export function useSearchParams<S extends z.ZodType>(schema: S): SearchParamsRes
|
||||
},
|
||||
set(v: unknown) {
|
||||
;(values as any)[key] = v
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const sp = new URLSearchParams(untrack(currentSearch))
|
||||
if (v == null) {
|
||||
sp.delete(key)
|
||||
} else {
|
||||
sp.set(key, serializeParam(v))
|
||||
}
|
||||
if (hosted) {
|
||||
hosted.setSearch(sp.size ? `?${sp}` : '')
|
||||
return
|
||||
}
|
||||
const hash = window.location.hash
|
||||
const newUrl = sp.toString()
|
||||
? `${window.location.pathname}?${sp}${hash}`
|
||||
|
||||
@@ -90,7 +90,11 @@
|
||||
import SessionModeSwitch from '$lib/components/sessions/SessionModeSwitch.svelte'
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import { parsePreviewItemRoute } from '$lib/components/sessions/previewPaths'
|
||||
import {
|
||||
isPageItemListPath,
|
||||
parsePreviewItemRoute,
|
||||
stripBase
|
||||
} from '$lib/components/sessions/previewPaths'
|
||||
import { rememberNavRoute } from '$lib/components/sessions/sessionSwitch.svelte'
|
||||
import { sessionState } from '$lib/components/sessions/sessionState.svelte'
|
||||
import { restoreSessionBackups } from '$lib/components/sessions/sessionMirror.svelte'
|
||||
@@ -431,6 +435,21 @@
|
||||
} catch {}
|
||||
return
|
||||
}
|
||||
// A list page mounts in process too, where its rows open as tabs of their own.
|
||||
const listUrl = navigation.to?.url
|
||||
if (listUrl && isPageItemListPath(stripBase(listUrl.pathname))) {
|
||||
navigation.cancel()
|
||||
const u = new URL(listUrl.href)
|
||||
u.searchParams.delete('nomenubar')
|
||||
u.searchParams.delete('workspace')
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{ type: 'wm.session.openList', href: u.pathname + u.search + u.hash },
|
||||
window.location.origin
|
||||
)
|
||||
} catch {}
|
||||
return
|
||||
}
|
||||
const runTarget = previewRunTarget(navigation.to?.url)
|
||||
if (runTarget) {
|
||||
navigation.cancel()
|
||||
|
||||
@@ -1,570 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { run } from 'svelte/legacy'
|
||||
|
||||
import {
|
||||
AmqpTriggerService,
|
||||
WorkspaceService,
|
||||
type AmqpTrigger,
|
||||
type TriggerMode,
|
||||
type WorkspaceDeployUISettings
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
canWrite,
|
||||
capitalize,
|
||||
displayDate,
|
||||
getLocalSetting,
|
||||
sendUserToast,
|
||||
storeLocalSetting,
|
||||
removeTriggerKindIfUnused
|
||||
} from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { base } from '$app/paths'
|
||||
import { page } from '$app/stores'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { enterpriseLicense, usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
|
||||
import { Code, Eye, Pen, Plus, Shield, Trash, Circle, FileUp, Pause } from 'lucide-svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import ListFilters from '$lib/components/home/ListFilters.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import AmqpTriggerEditor from '$lib/components/triggers/amqp/AmqpTriggerEditor.svelte'
|
||||
import AmqpIcon from '$lib/components/icons/AmqpIcon.svelte'
|
||||
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
|
||||
|
||||
type TriggerM = AmqpTrigger & { canWrite: boolean }
|
||||
|
||||
let triggers: TriggerM[] = $state([])
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
let loading = $state(true)
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
async function loadTriggers(): Promise<void> {
|
||||
triggers = (
|
||||
await AmqpTriggerService.listAmqpTriggers({
|
||||
workspace: $workspaceStore!,
|
||||
includeDraftOnly: true
|
||||
})
|
||||
).map((x) => {
|
||||
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
|
||||
})
|
||||
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'amqp', $usedTriggerKinds)
|
||||
loading = false
|
||||
}
|
||||
|
||||
let interval = setInterval(async () => {
|
||||
try {
|
||||
const newTriggers = await AmqpTriggerService.listAmqpTriggers({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
for (let i = 0; i < triggers.length; i++) {
|
||||
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
|
||||
if (newTrigger) {
|
||||
triggers[i] = {
|
||||
...triggers[i],
|
||||
error: newTrigger.error,
|
||||
last_server_ping: newTrigger.last_server_ping,
|
||||
mode: newTrigger.mode,
|
||||
server_id: newTrigger.server_id
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(interval)
|
||||
})
|
||||
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
|
||||
let committed = false
|
||||
try {
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
AmqpTriggerService.setAmqpTriggerMode({
|
||||
path,
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { mode, force }
|
||||
}),
|
||||
'AMQP trigger'
|
||||
)
|
||||
if (ok) {
|
||||
sendUserToast(`${capitalize(mode)} AMQP trigger ${path}`)
|
||||
loadTriggers()
|
||||
}
|
||||
committed = ok
|
||||
} catch (err) {
|
||||
sendUserToast(
|
||||
`Cannot ${mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend'} amqp trigger: ${err.body}`,
|
||||
true
|
||||
)
|
||||
loadTriggers()
|
||||
}
|
||||
return committed
|
||||
}
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore && $userStore) {
|
||||
loadTriggers()
|
||||
}
|
||||
})
|
||||
let amqpTriggerEditor: AmqpTriggerEditor | undefined = $state()
|
||||
|
||||
let hashHandled = false
|
||||
$effect(() => {
|
||||
if (!hashHandled && triggers.length > 0 && amqpTriggerEditor) {
|
||||
let hash = $page.url.hash
|
||||
if (hash.length > 1) {
|
||||
let path = hash.slice(1)
|
||||
let trigger = triggers.find((t) => t.path === path)
|
||||
if (trigger) {
|
||||
hashHandled = true
|
||||
amqpTriggerEditor?.openEdit(path, trigger.is_flow)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let filteredItems: (TriggerM & { marked?: any })[] | undefined = $state([])
|
||||
let items: typeof filteredItems | undefined = $state([])
|
||||
let preFilteredItems: typeof filteredItems | undefined = $state([])
|
||||
let filter = $state('')
|
||||
let ownerFilter: string | undefined = $state(undefined)
|
||||
let nbDisplayed = $state(15)
|
||||
|
||||
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
|
||||
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
|
||||
let selectedFilterKind = $state(
|
||||
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
|
||||
)
|
||||
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
|
||||
|
||||
run(() => {
|
||||
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
|
||||
})
|
||||
run(() => {
|
||||
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
|
||||
})
|
||||
|
||||
function filterItemsPathsBaseOnUserFilters(
|
||||
item: TriggerM,
|
||||
selectedFilterKind: 'trigger' | 'script_flow',
|
||||
filterUserFolders: boolean
|
||||
) {
|
||||
if ($workspaceStore == 'admins') return true
|
||||
if (filterUserFolders) {
|
||||
if (selectedFilterKind === 'trigger') {
|
||||
return (
|
||||
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
!item.script_path.startsWith('u/') ||
|
||||
item.script_path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
run(() => {
|
||||
preFilteredItems =
|
||||
ownerFilter != undefined
|
||||
? selectedFilterKind === 'trigger'
|
||||
? triggers?.filter(
|
||||
(x) =>
|
||||
x.path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter(
|
||||
(x) =>
|
||||
x.script_path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter((x) =>
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
})
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore) {
|
||||
ownerFilter = undefined
|
||||
}
|
||||
})
|
||||
|
||||
let owners = $derived(
|
||||
selectedFilterKind === 'trigger'
|
||||
? Array.from(
|
||||
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
: Array.from(
|
||||
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
)
|
||||
|
||||
run(() => {
|
||||
items = filter !== '' ? filteredItems : preFilteredItems
|
||||
})
|
||||
|
||||
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
|
||||
setQuery(
|
||||
TRIGGER_PATH_KIND_FILTER_SETTING,
|
||||
selectedFilterKind,
|
||||
window.location.hash || undefined
|
||||
).then(() => {
|
||||
setQuery(
|
||||
FILTER_USER_FOLDER_SETTING_NAME,
|
||||
String(filterUserFolders),
|
||||
window.location.hash || undefined
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function loadQueryFilters() {
|
||||
let url = new URL(window.location.href)
|
||||
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
|
||||
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
|
||||
if (queryFilterKind) {
|
||||
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
|
||||
}
|
||||
if (queryFilterUserFolders) {
|
||||
filterUserFolders = queryFilterUserFolders == 'true'
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadQueryFilters()
|
||||
})
|
||||
|
||||
run(() => {
|
||||
updateQueryFilters(selectedFilterKind, filterUserFolders)
|
||||
})
|
||||
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
|
||||
</script>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<AmqpTriggerEditor onUpdate={loadTriggers} bind:this={amqpTriggerEditor} />
|
||||
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={preFilteredItems}
|
||||
bind:filteredItems
|
||||
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
|
||||
/>
|
||||
|
||||
<CenteredPage>
|
||||
<PageHeader
|
||||
title="AMQP triggers"
|
||||
tooltip="Windmill can connect to an AMQP broker, subscribe to specific topics, and trigger scripts or flows based on those topics."
|
||||
>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => amqpTriggerEditor?.openNew(false)}
|
||||
>
|
||||
New AMQP trigger
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{#if isCloudHosted()}
|
||||
<Alert title="Not compatible with multi-tenant cloud" type="warning">
|
||||
AMQP triggers are disabled in the multi-tenant cloud.
|
||||
</Alert>
|
||||
<div class="py-4"></div>
|
||||
{/if}
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<div class="w-full pb-4 pt-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search AMQP triggers"
|
||||
bind:value={filter}
|
||||
class="search-item"
|
||||
/>
|
||||
<div class="flex flex-row items-center gap-2 mt-6">
|
||||
<div class="text-sm shrink-0"> Filter by path of </div>
|
||||
<ToggleButtonGroup bind:selected={selectedFilterKind}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="trigger" label="AMQP trigger" icon={AmqpIcon} {item} />
|
||||
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
|
||||
|
||||
<div class="flex flex-row items-center justify-end gap-4">
|
||||
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
|
||||
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
|
||||
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={filterUserFolders}
|
||||
options={{ right: `Only u/${$userStore.username} and f/*` }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if loading}
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[6], 0.4]} />
|
||||
{/each}
|
||||
{:else if !triggers?.length}
|
||||
<EmptyState
|
||||
icon={AmqpIcon}
|
||||
title="No AMQP triggers yet"
|
||||
description="Windmill can connect to an AMQP broker, subscribe to specific topics, and trigger scripts or flows based on those topics."
|
||||
action={{
|
||||
label: 'Add an AMQP trigger',
|
||||
icon: Plus,
|
||||
onClick: () => amqpTriggerEditor?.openNew(false),
|
||||
aiId: 'amqp-triggers-empty-add',
|
||||
aiDescription: 'Add AMQP trigger'
|
||||
}}
|
||||
/>
|
||||
{:else if items?.length}
|
||||
<div class="border rounded-md divide-y">
|
||||
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, extra_perms, canWrite, error, last_server_ping, server_id, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
|
||||
{@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_amqp', path) ?? is_draft}
|
||||
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
|
||||
{@const ping = last_server_ping ? new Date(last_server_ping) : undefined}
|
||||
{@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000}
|
||||
{@const effectiveMode = draft_only ? 'disabled' : mode}
|
||||
{@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'}
|
||||
|
||||
<div
|
||||
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
|
||||
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
|
||||
>
|
||||
<div class="w-full flex gap-5 items-center">
|
||||
<RowIcon kind={is_flow ? 'flow' : 'script'} />
|
||||
|
||||
<a
|
||||
href="#{path}"
|
||||
onclick={() => amqpTriggerEditor?.openEdit(path, is_flow)}
|
||||
class="min-w-0 grow hover:underline decoration-gray-400"
|
||||
>
|
||||
<div class="text-emphasis font-semibold text-xs truncate text-left">
|
||||
{path}{hasDraft ? '*' : ''}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left font-light">
|
||||
runnable: {script_path}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="hidden lg:flex flex-row gap-1 items-center">
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
{#if labels?.length}
|
||||
{#each labels as label}
|
||||
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="w-10">
|
||||
{#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle
|
||||
class="text-red-600 animate-ping absolute inline-flex fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
{#if enabled}
|
||||
{#if !server_id}
|
||||
AMQP trigger is starting...
|
||||
{:else}
|
||||
AMQP trigger is not connected{error ? ': ' + error : ''}
|
||||
{/if}
|
||||
{:else}
|
||||
AMQP trigger was disabled because of an error: {error}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else if enabled}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle class="text-green-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
AMQP trigger is connected{!server_id ? ' (shutting down...)' : ''}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
|
||||
<DraftBadge {draft_only} is_draft={hasDraft} />
|
||||
<TriggerModeToggle
|
||||
disabled={draft_only}
|
||||
title={draft_only
|
||||
? 'Draft only: deploy the trigger to enable it'
|
||||
: hasDraft
|
||||
? 'Enables/disables the deployed trigger; the draft is not affected'
|
||||
: undefined}
|
||||
onToggleMode={(newMode) => onToggleMode(path, newMode)}
|
||||
triggerMode={effectiveMode}
|
||||
includeModalConfig={{
|
||||
triggerPath: path,
|
||||
triggerKind: 'amqp',
|
||||
runnableConfig: {
|
||||
path: script_path,
|
||||
kind: is_flow ? 'flow' : 'script',
|
||||
retry,
|
||||
errorHandlerPath: error_handler_path,
|
||||
errorHandlerArgs: error_handler_args
|
||||
}
|
||||
}}
|
||||
{canWrite}
|
||||
hideToggleLabels
|
||||
hideDropdown
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center justify-end">
|
||||
<Button
|
||||
on:click={() => amqpTriggerEditor?.openEdit(path, is_flow)}
|
||||
unifiedSize="md"
|
||||
startIcon={canWrite
|
||||
? { icon: Pen }
|
||||
: {
|
||||
icon: Eye
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
{canWrite ? 'Edit' : 'View'}
|
||||
</Button>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
|
||||
icon: Eye,
|
||||
action: () => {
|
||||
goto(href)
|
||||
}
|
||||
},
|
||||
...(canWrite && !draft_only && mode !== 'suspended'
|
||||
? [
|
||||
{
|
||||
displayName: 'Suspend job execution',
|
||||
icon: Pause,
|
||||
action: () => {
|
||||
onToggleMode(path, 'suspended')
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: canWrite ? 'Edit' : 'View',
|
||||
icon: canWrite ? Pen : Eye,
|
||||
action: () => {
|
||||
amqpTriggerEditor?.openEdit(path, is_flow)
|
||||
}
|
||||
},
|
||||
...(isDeployable('trigger', path, deployUiSettings)
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer?.openDrawer(path, 'trigger', {
|
||||
triggers: {
|
||||
kind: 'amqp'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Audit logs',
|
||||
icon: Eye,
|
||||
href: `${base}/audit_logs?resource=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
action: () => {
|
||||
shareModal?.openDrawer(path, 'amqp_trigger')
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
type: 'delete',
|
||||
icon: Trash,
|
||||
disabled: !canWrite,
|
||||
action: async () => {
|
||||
await AmqpTriggerService.deleteAmqpTrigger({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path
|
||||
})
|
||||
loadTriggers()
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-between items-baseline">
|
||||
<div
|
||||
class="flex flex-wrap text-[0.7em] text-primary gap-1 items-center justify-end truncate pr-2"
|
||||
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
|
||||
class="truncate">{edited_by ? 'the ' : ''}{displayDate(edited_at)}</div
|
||||
></div
|
||||
></div
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<NoItemFound />
|
||||
{/if}
|
||||
</div>
|
||||
{#if items && items?.length > 15 && nbDisplayed < items.length}
|
||||
<span class="text-xs"
|
||||
>{nbDisplayed} items out of {items.length}
|
||||
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
|
||||
>
|
||||
{/if}
|
||||
</CenteredPage>
|
||||
|
||||
<ShareModal
|
||||
bind:this={shareModal}
|
||||
on:change={() => {
|
||||
loadTriggers()
|
||||
}}
|
||||
/>
|
||||
<TriggerList triggerKind="amqp" />
|
||||
|
||||
@@ -1,668 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { run } from 'svelte/legacy'
|
||||
|
||||
import {
|
||||
AzureTriggerService,
|
||||
WorkspaceService,
|
||||
type AzureTrigger,
|
||||
type WorkspaceDeployUISettings,
|
||||
type TriggerMode
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
canWrite,
|
||||
copyToClipboard,
|
||||
displayDate,
|
||||
getLocalSetting,
|
||||
sendUserToast,
|
||||
storeLocalSetting,
|
||||
removeTriggerKindIfUnused
|
||||
} from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { base } from '$app/paths'
|
||||
import { page } from '$app/stores'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import { Alert, Button, EmptyState, Skeleton } from '$lib/components/common'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { enterpriseLicense, usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
Code,
|
||||
Eye,
|
||||
Pen,
|
||||
Plus,
|
||||
Shield,
|
||||
Trash,
|
||||
Circle,
|
||||
FileUp,
|
||||
ClipboardCopy,
|
||||
Pause
|
||||
} from 'lucide-svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import ListFilters from '$lib/components/home/ListFilters.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import AzureTriggerEditor from '$lib/components/triggers/azure/AzureTriggerEditor.svelte'
|
||||
import AzureIcon from '$lib/components/icons/AzureIcon.svelte'
|
||||
import { getHttpRoute } from '$lib/components/triggers/http/utils'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
|
||||
|
||||
type TriggerD = AzureTrigger & { canWrite: boolean }
|
||||
|
||||
let triggers: TriggerD[] = $state([])
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
let loading = $state(true)
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
let isDeleting = $state(false)
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
async function loadTriggers(): Promise<void> {
|
||||
triggers = (
|
||||
await AzureTriggerService.listAzureTriggers({
|
||||
workspace: $workspaceStore!,
|
||||
includeDraftOnly: true
|
||||
})
|
||||
).map((x) => {
|
||||
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
|
||||
})
|
||||
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'azure', $usedTriggerKinds)
|
||||
loading = false
|
||||
}
|
||||
|
||||
let interval = setInterval(async () => {
|
||||
try {
|
||||
const newTriggers = await AzureTriggerService.listAzureTriggers({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
for (let i = 0; i < triggers.length; i++) {
|
||||
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
|
||||
if (newTrigger) {
|
||||
triggers[i] = {
|
||||
...triggers[i],
|
||||
error: newTrigger.error,
|
||||
last_server_ping: newTrigger.last_server_ping,
|
||||
mode: newTrigger.mode,
|
||||
server_id: newTrigger.server_id
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(interval)
|
||||
})
|
||||
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
|
||||
let committed = false
|
||||
try {
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
AzureTriggerService.setAzureTriggerMode({
|
||||
path,
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { mode, force }
|
||||
}),
|
||||
'Azure trigger'
|
||||
)
|
||||
committed = ok
|
||||
if (ok) loadTriggers()
|
||||
} catch (err) {
|
||||
sendUserToast(
|
||||
`Cannot ${mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend'} Azure Event Grid trigger: ${err.body}`,
|
||||
true
|
||||
)
|
||||
loadTriggers()
|
||||
}
|
||||
return committed
|
||||
}
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore && $userStore) {
|
||||
loadTriggers()
|
||||
}
|
||||
})
|
||||
let azureTriggerEditor: AzureTriggerEditor | undefined = $state()
|
||||
|
||||
let hashHandled = false
|
||||
$effect(() => {
|
||||
if (!hashHandled && triggers.length > 0 && azureTriggerEditor) {
|
||||
let hash = $page.url.hash
|
||||
if (hash.length > 1) {
|
||||
let path = hash.slice(1)
|
||||
let trigger = triggers.find((t) => t.path === path)
|
||||
if (trigger) {
|
||||
hashHandled = true
|
||||
azureTriggerEditor?.openEdit(path, trigger.is_flow)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let filteredItems: (TriggerD & { marked?: any })[] | undefined = $state([])
|
||||
let items: typeof filteredItems | undefined = $state([])
|
||||
let preFilteredItems: typeof filteredItems | undefined = $state([])
|
||||
let filter = $state('')
|
||||
let ownerFilter: string | undefined = $state(undefined)
|
||||
let nbDisplayed = $state(15)
|
||||
let deleteAzureTriggerCallback: (() => Promise<void>) | undefined = $state(undefined)
|
||||
let deleteSubscription = $state(false)
|
||||
let subscriptionToDelete: string | undefined = $state(undefined)
|
||||
let subscriptionDeletePayload:
|
||||
| {
|
||||
azure_mode: AzureTrigger['azure_mode']
|
||||
scope_resource_id: string
|
||||
topic_name?: string
|
||||
subscription_name: string
|
||||
azure_resource_path: string
|
||||
}
|
||||
| undefined = $state(undefined)
|
||||
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
|
||||
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
|
||||
let selectedFilterKind = $state(
|
||||
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
|
||||
)
|
||||
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
|
||||
|
||||
run(() => {
|
||||
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
|
||||
})
|
||||
run(() => {
|
||||
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
|
||||
})
|
||||
|
||||
function filterItemsPathsBaseOnUserFilters(
|
||||
item: TriggerD,
|
||||
selectedFilterKind: 'trigger' | 'script_flow',
|
||||
filterUserFolders: boolean
|
||||
) {
|
||||
if ($workspaceStore == 'admins') return true
|
||||
if (filterUserFolders) {
|
||||
if (selectedFilterKind === 'trigger') {
|
||||
return (
|
||||
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
!item.script_path.startsWith('u/') ||
|
||||
item.script_path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
run(() => {
|
||||
preFilteredItems =
|
||||
ownerFilter != undefined
|
||||
? selectedFilterKind === 'trigger'
|
||||
? triggers?.filter(
|
||||
(x) =>
|
||||
x.path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter(
|
||||
(x) =>
|
||||
x.script_path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter((x) =>
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
})
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore) {
|
||||
ownerFilter = undefined
|
||||
}
|
||||
})
|
||||
|
||||
let owners = $derived(
|
||||
selectedFilterKind === 'trigger'
|
||||
? Array.from(
|
||||
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
: Array.from(
|
||||
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
)
|
||||
|
||||
run(() => {
|
||||
items = filter !== '' ? filteredItems : preFilteredItems
|
||||
})
|
||||
|
||||
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
|
||||
setQuery(
|
||||
TRIGGER_PATH_KIND_FILTER_SETTING,
|
||||
selectedFilterKind,
|
||||
window.location.hash || undefined
|
||||
).then(() => {
|
||||
setQuery(
|
||||
FILTER_USER_FOLDER_SETTING_NAME,
|
||||
String(filterUserFolders),
|
||||
window.location.hash || undefined
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function loadQueryFilters() {
|
||||
let url = new URL(window.location.href)
|
||||
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
|
||||
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
|
||||
if (queryFilterKind) {
|
||||
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
|
||||
}
|
||||
if (queryFilterUserFolders) {
|
||||
filterUserFolders = queryFilterUserFolders == 'true'
|
||||
}
|
||||
}
|
||||
onMount(() => {
|
||||
loadQueryFilters()
|
||||
})
|
||||
|
||||
run(() => {
|
||||
updateQueryFilters(selectedFilterKind, filterUserFolders)
|
||||
})
|
||||
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
|
||||
</script>
|
||||
|
||||
<ConfirmationModal
|
||||
open={Boolean(deleteAzureTriggerCallback)}
|
||||
title="Delete Azure Event Grid trigger"
|
||||
confirmationText="Remove"
|
||||
loading={isDeleting}
|
||||
on:canceled={() => {
|
||||
deleteAzureTriggerCallback = undefined
|
||||
}}
|
||||
on:confirmed={async () => {
|
||||
if (deleteAzureTriggerCallback) {
|
||||
isDeleting = true
|
||||
if (deleteSubscription && subscriptionDeletePayload) {
|
||||
try {
|
||||
const msg = await AzureTriggerService.deleteAzureSubscription({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: subscriptionDeletePayload.azure_resource_path,
|
||||
requestBody: {
|
||||
azure_mode: subscriptionDeletePayload.azure_mode,
|
||||
scope_resource_id: subscriptionDeletePayload.scope_resource_id,
|
||||
topic_name: subscriptionDeletePayload.topic_name,
|
||||
subscription_name: subscriptionDeletePayload.subscription_name
|
||||
}
|
||||
})
|
||||
sendUserToast(msg)
|
||||
} catch (error) {
|
||||
isDeleting = false
|
||||
sendUserToast(error.body || error.message, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
await deleteAzureTriggerCallback()
|
||||
}
|
||||
deleteAzureTriggerCallback = undefined
|
||||
subscriptionToDelete = undefined
|
||||
subscriptionDeletePayload = undefined
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
<span>Are you sure you want to remove this trigger?</span>
|
||||
|
||||
{#if subscriptionToDelete}
|
||||
<Toggle
|
||||
options={{
|
||||
left: `Also delete Azure subscription "${subscriptionToDelete}" on Azure?`
|
||||
}}
|
||||
bind:checked={deleteSubscription}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<AzureTriggerEditor onUpdate={loadTriggers} bind:this={azureTriggerEditor} />
|
||||
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={preFilteredItems}
|
||||
bind:filteredItems
|
||||
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
|
||||
/>
|
||||
|
||||
<CenteredPage>
|
||||
<PageHeader title="Azure Event Grid triggers" tooltip="Azure Event Grid trigger">
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => azureTriggerEditor?.openNew(false)}
|
||||
>
|
||||
New Azure Event Grid trigger
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{#if isCloudHosted()}
|
||||
<Alert title="Not compatible with multi-tenant cloud" type="warning">
|
||||
Azure Event Grid triggers are disabled in the multi-tenant cloud.
|
||||
</Alert>
|
||||
{/if}
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<div class="w-full pb-4 pt-6">
|
||||
<input type="text" placeholder="Search triggers" bind:value={filter} class="search-item" />
|
||||
<div class="flex flex-row items-center gap-2 mt-6">
|
||||
<div class="text-sm shrink-0"> Filter by path of </div>
|
||||
<ToggleButtonGroup bind:selected={selectedFilterKind}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value="trigger"
|
||||
label="Azure Event Grid trigger"
|
||||
icon={AzureIcon}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
|
||||
|
||||
<div class="flex flex-row items-center justify-end gap-4">
|
||||
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
|
||||
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
|
||||
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={filterUserFolders}
|
||||
options={{ right: `Only u/${$userStore.username} and f/*` }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if loading}
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[6], 0.4]} />
|
||||
{/each}
|
||||
{:else if !triggers?.length}
|
||||
<EmptyState
|
||||
icon={AzureIcon}
|
||||
title="No Azure Event Grid triggers yet"
|
||||
description="Windmill can subscribe to an Azure Event Grid topic and trigger scripts or flows on each event."
|
||||
action={{
|
||||
label: 'Add an Azure Event Grid trigger',
|
||||
icon: Plus,
|
||||
onClick: () => azureTriggerEditor?.openNew(false),
|
||||
aiId: 'azure-triggers-empty-add',
|
||||
aiDescription: 'Add Azure Event Grid trigger'
|
||||
}}
|
||||
/>
|
||||
{:else if items?.length}
|
||||
<div class="border rounded-md divide-y">
|
||||
{#each items.slice(0, nbDisplayed) as { azure_resource_path, azure_mode, scope_resource_id, topic_name, subscription_name, workspace_id, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, retry, error_handler_path, error_handler_args, draft_only, is_draft } (path)}
|
||||
{@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_azure', path) ?? is_draft}
|
||||
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
|
||||
{@const ping = new Date()}
|
||||
{@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000}
|
||||
{@const effectiveMode = draft_only ? 'disabled' : mode}
|
||||
{@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'}
|
||||
{@const is_pull = azure_mode === 'namespace_pull'}
|
||||
{@const topic_label = topic_name
|
||||
? topic_name
|
||||
: (scope_resource_id?.split('/')?.pop() ?? '')}
|
||||
|
||||
<div
|
||||
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
|
||||
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
|
||||
>
|
||||
<div class="w-full flex gap-5 items-center">
|
||||
<RowIcon kind={is_flow ? 'flow' : 'script'} />
|
||||
|
||||
<a
|
||||
href="#{path}"
|
||||
onclick={() => azureTriggerEditor?.openEdit(path, is_flow)}
|
||||
class="min-w-0 grow hover:underline decoration-gray-400"
|
||||
>
|
||||
<div class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate">
|
||||
{path} - {topic_label} ({azure_mode}, {subscription_name}){hasDraft ? '*' : ''}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left font-light">
|
||||
runnable: {script_path}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="hidden lg:flex flex-row gap-1 items-center">
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
</div>
|
||||
|
||||
{#if is_pull}
|
||||
<div class="w-10">
|
||||
{#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle
|
||||
class="text-red-600 animate-ping absolute inline-flex fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
{#if enabled}
|
||||
{#if !server_id}
|
||||
Azure Event Grid trigger is starting...
|
||||
{:else}
|
||||
Could not connect to Azure Event Grid{error ? ': ' + error : ''}
|
||||
{/if}
|
||||
{:else}
|
||||
Disabled because of an error: {error}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else if enabled}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle
|
||||
class="text-green-600 relative inline-flex fill-current"
|
||||
size={12}
|
||||
/>
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
Connected to Azure Event Grid{!server_id
|
||||
? ' (shutting down...)'
|
||||
: ''}</div
|
||||
>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
|
||||
<DraftBadge {draft_only} is_draft={hasDraft} />
|
||||
{#if is_pull}
|
||||
<TriggerModeToggle
|
||||
disabled={draft_only}
|
||||
title={draft_only
|
||||
? 'Draft only: deploy the trigger to enable it'
|
||||
: hasDraft
|
||||
? 'Enables/disables the deployed trigger; the draft is not affected'
|
||||
: undefined}
|
||||
onToggleMode={(newMode) => onToggleMode(path, newMode)}
|
||||
triggerMode={effectiveMode}
|
||||
includeModalConfig={{
|
||||
triggerPath: path,
|
||||
triggerKind: 'azure',
|
||||
runnableConfig: {
|
||||
path: script_path,
|
||||
kind: is_flow ? 'flow' : 'script',
|
||||
retry,
|
||||
errorHandlerPath: error_handler_path,
|
||||
errorHandlerArgs: error_handler_args
|
||||
}
|
||||
}}
|
||||
{canWrite}
|
||||
hideToggleLabels
|
||||
hideDropdown
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center justify-end">
|
||||
{#if !is_pull}
|
||||
<Button
|
||||
on:click={() =>
|
||||
copyToClipboard(getHttpRoute('azure/w', path, true, workspace_id))}
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
startIcon={{ icon: ClipboardCopy }}
|
||||
>
|
||||
Copy URL
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
on:click={() => azureTriggerEditor?.openEdit(path, is_flow)}
|
||||
size="xs"
|
||||
startIcon={canWrite
|
||||
? { icon: Pen }
|
||||
: {
|
||||
icon: Eye
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
{canWrite ? 'Edit' : 'View'}
|
||||
</Button>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
|
||||
icon: Eye,
|
||||
action: () => {
|
||||
goto(href)
|
||||
}
|
||||
},
|
||||
...(canWrite && !draft_only && mode !== 'suspended'
|
||||
? [
|
||||
{
|
||||
displayName: 'Suspend job execution',
|
||||
icon: Pause,
|
||||
action: () => {
|
||||
onToggleMode(path, 'suspended')
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: canWrite ? 'Edit' : 'View',
|
||||
icon: canWrite ? Pen : Eye,
|
||||
action: () => {
|
||||
azureTriggerEditor?.openEdit(path, is_flow)
|
||||
}
|
||||
},
|
||||
...(isDeployable('trigger', path, deployUiSettings)
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer?.openDrawer(path, 'trigger', {
|
||||
triggers: {
|
||||
kind: 'azure'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Audit logs',
|
||||
icon: Eye,
|
||||
href: `${base}/audit_logs?resource=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
action: () => {
|
||||
shareModal?.openDrawer(path, 'azure_trigger')
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
type: 'delete',
|
||||
icon: Trash,
|
||||
disabled: !canWrite,
|
||||
action: async () => {
|
||||
isDeleting = false
|
||||
deleteSubscription = false
|
||||
subscriptionToDelete = subscription_name
|
||||
subscriptionDeletePayload = {
|
||||
azure_mode,
|
||||
scope_resource_id,
|
||||
topic_name: topic_name ?? undefined,
|
||||
subscription_name,
|
||||
azure_resource_path
|
||||
}
|
||||
deleteAzureTriggerCallback = async () => {
|
||||
const message = await AzureTriggerService.deleteAzureTrigger({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path
|
||||
})
|
||||
sendUserToast(message)
|
||||
loadTriggers()
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-between items-baseline">
|
||||
<div
|
||||
class="flex flex-wrap text-[0.7em] text-primary gap-1 items-center justify-end truncate pr-2"
|
||||
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
|
||||
class="truncate">{edited_by ? 'the ' : ''}{displayDate(edited_at)}</div
|
||||
></div
|
||||
></div
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<NoItemFound />
|
||||
{/if}
|
||||
</div>
|
||||
{#if items && items?.length > 15 && nbDisplayed < items.length}
|
||||
<span class="text-xs"
|
||||
>{nbDisplayed} items out of {items.length}
|
||||
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
|
||||
>
|
||||
{/if}
|
||||
</CenteredPage>
|
||||
|
||||
<ShareModal
|
||||
bind:this={shareModal}
|
||||
on:change={() => {
|
||||
loadTriggers()
|
||||
}}
|
||||
/>
|
||||
<TriggerList triggerKind="azure" />
|
||||
|
||||
@@ -1,553 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { run } from 'svelte/legacy'
|
||||
|
||||
import {
|
||||
EmailTriggerService,
|
||||
WorkspaceService,
|
||||
type EmailTrigger,
|
||||
type TriggerMode,
|
||||
type WorkspaceDeployUISettings
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
canWrite,
|
||||
copyToClipboard,
|
||||
displayDate,
|
||||
getLocalSetting,
|
||||
storeLocalSetting,
|
||||
removeTriggerKindIfUnused,
|
||||
sendUserToast
|
||||
} from '$lib/utils'
|
||||
import { base } from '$app/paths'
|
||||
import { page } from '$app/stores'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import { Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import {
|
||||
userStore,
|
||||
workspaceStore,
|
||||
userWorkspaces,
|
||||
enterpriseLicense,
|
||||
usedTriggerKinds
|
||||
} from '$lib/stores'
|
||||
import {
|
||||
Mail,
|
||||
Code,
|
||||
Eye,
|
||||
Pen,
|
||||
Plus,
|
||||
Shield,
|
||||
Trash,
|
||||
FileUp,
|
||||
ClipboardCopy,
|
||||
Pause
|
||||
} from 'lucide-svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import ListFilters from '$lib/components/home/ListFilters.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import { onMount } from 'svelte'
|
||||
import EmailTriggerEditor from '$lib/components/triggers/email/EmailTriggerEditor.svelte'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
|
||||
import { getEmailAddress, getEmailDomain } from '$lib/components/triggers/email/utils'
|
||||
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
|
||||
|
||||
type TriggerW = EmailTrigger & { canWrite: boolean }
|
||||
|
||||
let triggers: TriggerW[] = $state([])
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
let loading = $state(true)
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
let emailDomain: string | null = $state(null)
|
||||
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
async function loadTriggers(): Promise<void> {
|
||||
triggers = (
|
||||
await EmailTriggerService.listEmailTriggers({
|
||||
workspace: $workspaceStore!,
|
||||
includeDraftOnly: true
|
||||
})
|
||||
).map((x) => {
|
||||
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
|
||||
})
|
||||
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'emails', $usedTriggerKinds)
|
||||
emailDomain = await getEmailDomain()
|
||||
loading = false
|
||||
}
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore && $userStore) {
|
||||
loadTriggers()
|
||||
}
|
||||
})
|
||||
let emailTriggerEditor: EmailTriggerEditor | undefined = $state()
|
||||
|
||||
let hashHandled = false
|
||||
$effect(() => {
|
||||
if (!hashHandled && triggers.length > 0 && emailTriggerEditor) {
|
||||
let hash = $page.url.hash
|
||||
if (hash.length > 1) {
|
||||
let path = hash.slice(1)
|
||||
let trigger = triggers.find((t) => t.path === path)
|
||||
if (trigger) {
|
||||
hashHandled = true
|
||||
emailTriggerEditor?.openEdit(path, trigger.is_flow)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let filteredItems: (TriggerW & { marked?: any })[] | undefined = $state([])
|
||||
let items: typeof filteredItems | undefined = $state([])
|
||||
let preFilteredItems: typeof filteredItems | undefined = $state([])
|
||||
let filter = $state('')
|
||||
let ownerFilter: string | undefined = $state(undefined)
|
||||
let nbDisplayed = $state(15)
|
||||
|
||||
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
|
||||
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
|
||||
let selectedFilterKind = $state(
|
||||
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
|
||||
)
|
||||
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
|
||||
|
||||
run(() => {
|
||||
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
|
||||
})
|
||||
run(() => {
|
||||
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
|
||||
})
|
||||
|
||||
function filterItemsPathsBaseOnUserFilters(
|
||||
item: TriggerW,
|
||||
selectedFilterKind: 'trigger' | 'script_flow',
|
||||
filterUserFolders: boolean
|
||||
) {
|
||||
if ($workspaceStore == 'admins') return true
|
||||
if (filterUserFolders) {
|
||||
if (selectedFilterKind === 'trigger') {
|
||||
return (
|
||||
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
!item.script_path.startsWith('u/') ||
|
||||
item.script_path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
run(() => {
|
||||
preFilteredItems =
|
||||
ownerFilter != undefined
|
||||
? selectedFilterKind === 'trigger'
|
||||
? triggers?.filter(
|
||||
(x) =>
|
||||
x.path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter(
|
||||
(x) =>
|
||||
x.script_path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter((x) =>
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
})
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore) {
|
||||
ownerFilter = undefined
|
||||
}
|
||||
})
|
||||
|
||||
let owners = $derived(
|
||||
selectedFilterKind === 'trigger'
|
||||
? Array.from(
|
||||
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
: Array.from(
|
||||
new Set(
|
||||
filteredItems
|
||||
?.filter((x) => x.script_path)
|
||||
.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? []
|
||||
)
|
||||
).sort()
|
||||
)
|
||||
|
||||
run(() => {
|
||||
items = filter !== '' ? filteredItems : preFilteredItems
|
||||
})
|
||||
|
||||
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
|
||||
setQuery(
|
||||
TRIGGER_PATH_KIND_FILTER_SETTING,
|
||||
selectedFilterKind,
|
||||
window.location.hash || undefined
|
||||
).then(() => {
|
||||
setQuery(
|
||||
FILTER_USER_FOLDER_SETTING_NAME,
|
||||
String(filterUserFolders),
|
||||
window.location.hash || undefined
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function loadQueryFilters() {
|
||||
let url = new URL(window.location.href)
|
||||
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
|
||||
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
|
||||
if (queryFilterKind) {
|
||||
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
|
||||
}
|
||||
if (queryFilterUserFolders) {
|
||||
filterUserFolders = queryFilterUserFolders == 'true'
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<void> {
|
||||
try {
|
||||
// Email addresses are always workspace-prefixed (clone filter
|
||||
// excludes workspaced_local_part=false) — no fork-conflict warning.
|
||||
await EmailTriggerService.setEmailTriggerMode({
|
||||
path,
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { mode }
|
||||
})
|
||||
} catch (err) {
|
||||
sendUserToast(
|
||||
`Cannot ` +
|
||||
(mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend') +
|
||||
` email trigger: ${err.body}`,
|
||||
true
|
||||
)
|
||||
} finally {
|
||||
loadTriggers()
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadQueryFilters()
|
||||
})
|
||||
|
||||
run(() => {
|
||||
updateQueryFilters(selectedFilterKind, filterUserFolders)
|
||||
})
|
||||
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
|
||||
</script>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<EmailTriggerEditor onUpdate={loadTriggers} bind:this={emailTriggerEditor} />
|
||||
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={preFilteredItems}
|
||||
bind:filteredItems
|
||||
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
|
||||
/>
|
||||
|
||||
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers}
|
||||
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
|
||||
<p class="font-bold">Unauthorized</p>
|
||||
<p>Page not available for operators</p>
|
||||
</div>
|
||||
{:else}
|
||||
<CenteredPage>
|
||||
<PageHeader
|
||||
title="Custom email triggers"
|
||||
tooltip="Every script and flow already has a canonical email trigger attached to it, this is to create additional parametrizable ones."
|
||||
documentationLink="https://www.windmill.dev/docs/advanced/email_triggers"
|
||||
>
|
||||
{#if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => emailTriggerEditor?.openNew(false)}
|
||||
>
|
||||
New email trigger
|
||||
</Button>
|
||||
{/if}
|
||||
</PageHeader>
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<div class="w-full pb-4 pt-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search email triggers"
|
||||
bind:value={filter}
|
||||
class="search-item"
|
||||
/>
|
||||
<div class="flex flex-row items-center gap-2 mt-6">
|
||||
<div class="text-sm shrink-0"> Filter by path of </div>
|
||||
<ToggleButtonGroup bind:selected={selectedFilterKind}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton small value="trigger" label="Email" icon={Mail} {item} />
|
||||
<ToggleButton small value="script_flow" label="Script/Flow" icon={Code} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
|
||||
|
||||
<div class="flex flex-row items-center justify-end gap-4">
|
||||
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
|
||||
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
|
||||
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={filterUserFolders}
|
||||
options={{ right: `Only u/${$userStore.username} and f/*` }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if loading}
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[6], 0.4]} />
|
||||
{/each}
|
||||
{:else if !triggers?.length}
|
||||
<EmptyState
|
||||
icon={Mail}
|
||||
title="No custom email triggers yet"
|
||||
description="Every script and flow already has a canonical email trigger attached to it. These are additional parametrizable ones."
|
||||
action={$userStore?.is_admin || $userStore?.is_super_admin
|
||||
? {
|
||||
label: 'Add an email trigger',
|
||||
icon: Plus,
|
||||
onClick: () => emailTriggerEditor?.openNew(false),
|
||||
aiId: 'email-triggers-empty-add',
|
||||
aiDescription: 'Add email trigger'
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
{:else if items?.length}
|
||||
<div class="border rounded-md divide-y">
|
||||
{#each items.slice(0, nbDisplayed) as { workspace_id, workspaced_local_part, path, edited_by, edited_at, script_path, is_flow, extra_perms, canWrite, marked, local_part, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
|
||||
{@const hasDraft =
|
||||
getLocalDraftHint($workspaceStore, 'trigger_email', path) ?? is_draft}
|
||||
{@const effectiveMode = draft_only ? 'disabled' : mode}
|
||||
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
|
||||
{@const emailAddress = getEmailAddress(
|
||||
local_part,
|
||||
workspaced_local_part ?? false,
|
||||
workspace_id,
|
||||
emailDomain ?? ''
|
||||
)}
|
||||
|
||||
<div
|
||||
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
|
||||
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
|
||||
>
|
||||
<div class="w-full flex gap-5 items-center">
|
||||
<RowIcon kind={is_flow ? 'flow' : 'script'} />
|
||||
|
||||
<a
|
||||
href="#{path}"
|
||||
onclick={() => emailTriggerEditor?.openEdit(path, is_flow)}
|
||||
class="min-w-0 grow hover:underline decoration-gray-400"
|
||||
>
|
||||
<div
|
||||
class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate"
|
||||
>
|
||||
{#if marked}
|
||||
<span class="text-xs">
|
||||
{@html marked}
|
||||
</span>
|
||||
{:else}
|
||||
{emailAddress}
|
||||
{/if}{hasDraft ? '*' : ''}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left font-light">
|
||||
{path}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left font-light">
|
||||
runnable: {script_path}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="hidden lg:flex flex-row gap-1 items-center">
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
{#if labels?.length}
|
||||
{#each labels as label}
|
||||
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
|
||||
<DraftBadge {draft_only} is_draft={hasDraft} />
|
||||
<TriggerModeToggle
|
||||
disabled={draft_only}
|
||||
title={draft_only
|
||||
? 'Draft only: deploy the trigger to enable it'
|
||||
: hasDraft
|
||||
? 'Enables/disables the deployed trigger; the draft is not affected'
|
||||
: undefined}
|
||||
onToggleMode={(newMode) => onToggleMode(path, newMode)}
|
||||
triggerMode={effectiveMode}
|
||||
includeModalConfig={{
|
||||
triggerPath: path,
|
||||
triggerKind: 'email',
|
||||
runnableConfig: {
|
||||
path: script_path,
|
||||
kind: is_flow ? 'flow' : 'script',
|
||||
retry,
|
||||
errorHandlerPath: error_handler_path,
|
||||
errorHandlerArgs: error_handler_args
|
||||
}
|
||||
}}
|
||||
{canWrite}
|
||||
hideToggleLabels
|
||||
hideDropdown
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center justify-end">
|
||||
<Button
|
||||
on:click={() => copyToClipboard(emailAddress)}
|
||||
variant="subtle"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: ClipboardCopy }}
|
||||
>
|
||||
Copy email address
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => emailTriggerEditor?.openEdit(path, is_flow)}
|
||||
unifiedSize="md"
|
||||
startIcon={canWrite
|
||||
? { icon: Pen }
|
||||
: {
|
||||
icon: Eye
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
{canWrite ? 'Edit' : 'View'}
|
||||
</Button>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
|
||||
icon: Eye,
|
||||
action: () => {
|
||||
goto(href)
|
||||
}
|
||||
},
|
||||
...(canWrite && !draft_only && mode !== 'suspended'
|
||||
? [
|
||||
{
|
||||
displayName: 'Suspend job execution',
|
||||
icon: Pause,
|
||||
action: () => {
|
||||
onToggleMode(path, 'suspended')
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: canWrite ? 'Edit' : 'View',
|
||||
icon: canWrite ? Pen : Eye,
|
||||
action: () => {
|
||||
emailTriggerEditor?.openEdit(path, is_flow)
|
||||
}
|
||||
},
|
||||
...(isDeployable('trigger', path, deployUiSettings)
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer?.openDrawer(path, 'trigger', {
|
||||
triggers: {
|
||||
kind: 'routes'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Audit logs',
|
||||
icon: Eye,
|
||||
href: `${base}/audit_logs?resource=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
action: () => {
|
||||
shareModal?.openDrawer(path, 'email_trigger')
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
type: 'delete',
|
||||
icon: Trash,
|
||||
disabled:
|
||||
!canWrite || !($userStore?.is_admin || $userStore?.is_super_admin),
|
||||
action: async () => {
|
||||
try {
|
||||
await EmailTriggerService.deleteEmailTrigger({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path
|
||||
})
|
||||
sendUserToast(`Successfully deleted Email trigger: ${path}`)
|
||||
loadTriggers()
|
||||
} catch (error) {
|
||||
sendUserToast(error.body || error.message, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-between items-baseline">
|
||||
<div
|
||||
class="flex flex-wrap text-[0.7em] text-primary gap-1 items-center justify-end truncate pr-2"
|
||||
>
|
||||
{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}
|
||||
<div class="truncate">{edited_by ? 'at ' : ''}{displayDate(edited_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<NoItemFound />
|
||||
{/if}
|
||||
</div>
|
||||
{#if items && items?.length > 15 && nbDisplayed < items.length}
|
||||
<span class="text-xs"
|
||||
>{nbDisplayed} items out of {items.length}
|
||||
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
|
||||
>
|
||||
{/if}
|
||||
</CenteredPage>
|
||||
{/if}
|
||||
|
||||
<ShareModal
|
||||
bind:this={shareModal}
|
||||
on:change={() => {
|
||||
loadTriggers()
|
||||
}}
|
||||
/>
|
||||
<TriggerList triggerKind="email" />
|
||||
|
||||
@@ -1,654 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { run } from 'svelte/legacy'
|
||||
|
||||
import {
|
||||
GcpTriggerService,
|
||||
WorkspaceService,
|
||||
type GcpTrigger,
|
||||
type WorkspaceDeployUISettings,
|
||||
type TriggerMode
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
canWrite,
|
||||
copyToClipboard,
|
||||
displayDate,
|
||||
getLocalSetting,
|
||||
sendUserToast,
|
||||
storeLocalSetting,
|
||||
removeTriggerKindIfUnused
|
||||
} from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { base } from '$app/paths'
|
||||
import { page } from '$app/stores'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { enterpriseLicense, usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
Code,
|
||||
Eye,
|
||||
Pen,
|
||||
Plus,
|
||||
Shield,
|
||||
Trash,
|
||||
Circle,
|
||||
FileUp,
|
||||
ClipboardCopy,
|
||||
Pause
|
||||
} from 'lucide-svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import ListFilters from '$lib/components/home/ListFilters.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import GcpTriggerEditor from '$lib/components/triggers/gcp/GcpTriggerEditor.svelte'
|
||||
import GoogleCloudIcon from '$lib/components/icons/GoogleCloudIcon.svelte'
|
||||
import { getHttpRoute } from '$lib/components/triggers/http/utils'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
|
||||
|
||||
type TriggerD = GcpTrigger & { canWrite: boolean }
|
||||
|
||||
let triggers: TriggerD[] = $state([])
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
let loading = $state(true)
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
let isDeleting = $state(false)
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
async function loadTriggers(): Promise<void> {
|
||||
triggers = (
|
||||
await GcpTriggerService.listGcpTriggers({
|
||||
workspace: $workspaceStore!,
|
||||
includeDraftOnly: true
|
||||
})
|
||||
).map((x) => {
|
||||
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
|
||||
})
|
||||
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'gcp', $usedTriggerKinds)
|
||||
loading = false
|
||||
}
|
||||
|
||||
let interval = setInterval(async () => {
|
||||
try {
|
||||
const newTriggers = await GcpTriggerService.listGcpTriggers({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
for (let i = 0; i < triggers.length; i++) {
|
||||
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
|
||||
if (newTrigger) {
|
||||
triggers[i] = {
|
||||
...triggers[i],
|
||||
error: newTrigger.error,
|
||||
last_server_ping: newTrigger.last_server_ping,
|
||||
mode: newTrigger.mode,
|
||||
server_id: newTrigger.server_id
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(interval)
|
||||
})
|
||||
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
|
||||
let committed = false
|
||||
try {
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
GcpTriggerService.setGcpTriggerMode({
|
||||
path,
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { mode, force }
|
||||
}),
|
||||
'GCP Pub/Sub trigger'
|
||||
)
|
||||
committed = ok
|
||||
if (ok) loadTriggers()
|
||||
} catch (err) {
|
||||
sendUserToast(
|
||||
`Cannot ${mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend'} GCP Pub/Sub trigger: ${err.body}`,
|
||||
true
|
||||
)
|
||||
loadTriggers()
|
||||
}
|
||||
return committed
|
||||
}
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore && $userStore) {
|
||||
loadTriggers()
|
||||
}
|
||||
})
|
||||
let gcpTriggerEditor: GcpTriggerEditor | undefined = $state()
|
||||
|
||||
let hashHandled = false
|
||||
$effect(() => {
|
||||
if (!hashHandled && triggers.length > 0 && gcpTriggerEditor) {
|
||||
let hash = $page.url.hash
|
||||
if (hash.length > 1) {
|
||||
let path = hash.slice(1)
|
||||
let trigger = triggers.find((t) => t.path === path)
|
||||
if (trigger) {
|
||||
hashHandled = true
|
||||
gcpTriggerEditor?.openEdit(path, trigger.is_flow)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let filteredItems: (TriggerD & { marked?: any })[] | undefined = $state([])
|
||||
let items: typeof filteredItems | undefined = $state([])
|
||||
let preFilteredItems: typeof filteredItems | undefined = $state([])
|
||||
let filter = $state('')
|
||||
let ownerFilter: string | undefined = $state(undefined)
|
||||
let nbDisplayed = $state(15)
|
||||
let deleteSubscription = $state(false)
|
||||
let deleteSubscriptionCallback: (() => Promise<void>) | undefined = $state(undefined)
|
||||
let deleteGcpTriggerCallback: (() => Promise<void>) | undefined = $state(undefined)
|
||||
let subscriptionToDelete: string = $state('')
|
||||
let currentTopic: string = $state('')
|
||||
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
|
||||
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
|
||||
let selectedFilterKind = $state(
|
||||
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
|
||||
)
|
||||
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
|
||||
|
||||
run(() => {
|
||||
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
|
||||
})
|
||||
run(() => {
|
||||
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
|
||||
})
|
||||
|
||||
function filterItemsPathsBaseOnUserFilters(
|
||||
item: TriggerD,
|
||||
selectedFilterKind: 'trigger' | 'script_flow',
|
||||
filterUserFolders: boolean
|
||||
) {
|
||||
if ($workspaceStore == 'admins') return true
|
||||
if (filterUserFolders) {
|
||||
if (selectedFilterKind === 'trigger') {
|
||||
return (
|
||||
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
!item.script_path.startsWith('u/') ||
|
||||
item.script_path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
run(() => {
|
||||
preFilteredItems =
|
||||
ownerFilter != undefined
|
||||
? selectedFilterKind === 'trigger'
|
||||
? triggers?.filter(
|
||||
(x) =>
|
||||
x.path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter(
|
||||
(x) =>
|
||||
x.script_path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter((x) =>
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
})
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore) {
|
||||
ownerFilter = undefined
|
||||
}
|
||||
})
|
||||
|
||||
let owners = $derived(
|
||||
selectedFilterKind === 'trigger'
|
||||
? Array.from(
|
||||
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
: Array.from(
|
||||
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
)
|
||||
|
||||
run(() => {
|
||||
items = filter !== '' ? filteredItems : preFilteredItems
|
||||
})
|
||||
|
||||
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
|
||||
setQuery(
|
||||
TRIGGER_PATH_KIND_FILTER_SETTING,
|
||||
selectedFilterKind,
|
||||
window.location.hash || undefined
|
||||
).then(() => {
|
||||
setQuery(
|
||||
FILTER_USER_FOLDER_SETTING_NAME,
|
||||
String(filterUserFolders),
|
||||
window.location.hash || undefined
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function loadQueryFilters() {
|
||||
let url = new URL(window.location.href)
|
||||
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
|
||||
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
|
||||
if (queryFilterKind) {
|
||||
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
|
||||
}
|
||||
if (queryFilterUserFolders) {
|
||||
filterUserFolders = queryFilterUserFolders == 'true'
|
||||
}
|
||||
}
|
||||
onMount(() => {
|
||||
loadQueryFilters()
|
||||
})
|
||||
|
||||
run(() => {
|
||||
updateQueryFilters(selectedFilterKind, filterUserFolders)
|
||||
})
|
||||
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
|
||||
</script>
|
||||
|
||||
<ConfirmationModal
|
||||
open={Boolean(deleteGcpTriggerCallback)}
|
||||
title="Delete GCP Pub/Sub trigger"
|
||||
confirmationText="Remove"
|
||||
loading={isDeleting}
|
||||
on:canceled={() => {
|
||||
deleteGcpTriggerCallback = undefined
|
||||
}}
|
||||
on:confirmed={async () => {
|
||||
if (deleteGcpTriggerCallback) {
|
||||
isDeleting = true
|
||||
if (deleteSubscription && deleteSubscriptionCallback) {
|
||||
try {
|
||||
await deleteSubscriptionCallback()
|
||||
} catch (error) {
|
||||
isDeleting = false
|
||||
sendUserToast(error.body || error.message, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
await deleteGcpTriggerCallback()
|
||||
}
|
||||
deleteGcpTriggerCallback = undefined
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
<span>Are you sure you want to remove this trigger?</span>
|
||||
|
||||
<Toggle
|
||||
options={{
|
||||
left: `Delete subscription "${subscriptionToDelete}" subscribed to topic "${currentTopic}"?`
|
||||
}}
|
||||
bind:checked={deleteSubscription}
|
||||
/>
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<GcpTriggerEditor onUpdate={loadTriggers} bind:this={gcpTriggerEditor} />
|
||||
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={preFilteredItems}
|
||||
bind:filteredItems
|
||||
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
|
||||
/>
|
||||
|
||||
<CenteredPage>
|
||||
<PageHeader title="GCP Pub/Sub triggers" tooltip="GCP Pub/Sub trigger">
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => gcpTriggerEditor?.openNew(false)}
|
||||
>
|
||||
New GCP Pub/Sub trigger
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{#if isCloudHosted()}
|
||||
<Alert title="Not compatible with multi-tenant cloud" type="warning">
|
||||
GCP Pub/Sub triggers are disabled in the multi-tenant cloud.
|
||||
</Alert>
|
||||
{/if}
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<div class="w-full pb-4 pt-6">
|
||||
<input type="text" placeholder="Search triggers" bind:value={filter} class="search-item" />
|
||||
<div class="flex flex-row items-center gap-2 mt-6">
|
||||
<div class="text-sm shrink-0"> Filter by path of </div>
|
||||
<ToggleButtonGroup bind:selected={selectedFilterKind}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value="trigger"
|
||||
label="GCP Pub/Sub trigger"
|
||||
icon={GoogleCloudIcon}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
|
||||
|
||||
<div class="flex flex-row items-center justify-end gap-4">
|
||||
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
|
||||
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
|
||||
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={filterUserFolders}
|
||||
options={{ right: `Only u/${$userStore.username} and f/*` }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if loading}
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[6], 0.4]} />
|
||||
{/each}
|
||||
{:else if !triggers?.length}
|
||||
<EmptyState
|
||||
icon={GoogleCloudIcon}
|
||||
title="No GCP Pub/Sub triggers yet"
|
||||
description="Windmill can subscribe to a GCP Pub/Sub topic and trigger scripts or flows on each message."
|
||||
action={{
|
||||
label: 'Add a GCP Pub/Sub trigger',
|
||||
icon: Plus,
|
||||
onClick: () => gcpTriggerEditor?.openNew(false),
|
||||
aiId: 'gcp-triggers-empty-add',
|
||||
aiDescription: 'Add GCP Pub/Sub trigger'
|
||||
}}
|
||||
/>
|
||||
{:else if items?.length}
|
||||
<div class="border rounded-md divide-y">
|
||||
{#each items.slice(0, nbDisplayed) as { gcp_resource_path, project_id, topic_id, workspace_id, delivery_type, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, subscription_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
|
||||
{@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_gcp', path) ?? is_draft}
|
||||
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
|
||||
{@const ping = new Date()}
|
||||
{@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000}
|
||||
{@const effectiveMode = draft_only ? 'disabled' : mode}
|
||||
{@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'}
|
||||
|
||||
<div
|
||||
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
|
||||
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
|
||||
>
|
||||
<div class="w-full flex gap-5 items-center">
|
||||
<RowIcon kind={is_flow ? 'flow' : 'script'} />
|
||||
|
||||
<a
|
||||
href="#{path}"
|
||||
onclick={() => gcpTriggerEditor?.openEdit(path, is_flow)}
|
||||
class="min-w-0 grow hover:underline decoration-gray-400"
|
||||
>
|
||||
<div class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate">
|
||||
{path} - {topic_id}{hasDraft ? '*' : ''}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left font-light">
|
||||
runnable: {script_path}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="hidden lg:flex flex-row gap-1 items-center">
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
{#if labels?.length}
|
||||
{#each labels as label}
|
||||
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if delivery_type !== 'push'}
|
||||
<div class="w-10">
|
||||
{#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle
|
||||
class="text-red-600 animate-ping absolute inline-flex fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
{#if enabled}
|
||||
{#if !server_id}
|
||||
GCP Pub/Sub trigger is starting...
|
||||
{:else}
|
||||
Could not connect to GCP Pub/Sub{error ? ': ' + error : ''}
|
||||
{/if}
|
||||
{:else}
|
||||
Disabled because of an error: {error}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else if enabled}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle
|
||||
class="text-green-600 relative inline-flex fill-current"
|
||||
size={12}
|
||||
/>
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
Connected to GCP Pub/Sub{!server_id ? ' (shutting down...)' : ''}</div
|
||||
>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
|
||||
<DraftBadge {draft_only} is_draft={hasDraft} />
|
||||
{#if delivery_type !== 'push'}
|
||||
<TriggerModeToggle
|
||||
disabled={draft_only}
|
||||
title={draft_only
|
||||
? 'Draft only: deploy the trigger to enable it'
|
||||
: hasDraft
|
||||
? 'Enables/disables the deployed trigger; the draft is not affected'
|
||||
: undefined}
|
||||
onToggleMode={(newMode) => onToggleMode(path, newMode)}
|
||||
triggerMode={effectiveMode}
|
||||
includeModalConfig={{
|
||||
triggerPath: path,
|
||||
triggerKind: 'gcp',
|
||||
runnableConfig: {
|
||||
path: script_path,
|
||||
kind: is_flow ? 'flow' : 'script',
|
||||
retry,
|
||||
errorHandlerPath: error_handler_path,
|
||||
errorHandlerArgs: error_handler_args
|
||||
}
|
||||
}}
|
||||
{canWrite}
|
||||
hideToggleLabels
|
||||
hideDropdown
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center justify-end">
|
||||
{#if delivery_type === 'push'}
|
||||
<Button
|
||||
on:click={() =>
|
||||
copyToClipboard(getHttpRoute('gcp/w', path, true, workspace_id))}
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
startIcon={{ icon: ClipboardCopy }}
|
||||
>
|
||||
Copy URL
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
on:click={() => gcpTriggerEditor?.openEdit(path, is_flow)}
|
||||
size="xs"
|
||||
startIcon={canWrite
|
||||
? { icon: Pen }
|
||||
: {
|
||||
icon: Eye
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
{canWrite ? 'Edit' : 'View'}
|
||||
</Button>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
|
||||
icon: Eye,
|
||||
action: () => {
|
||||
goto(href)
|
||||
}
|
||||
},
|
||||
...(canWrite && !draft_only && mode !== 'suspended'
|
||||
? [
|
||||
{
|
||||
displayName: 'Suspend job execution',
|
||||
icon: Pause,
|
||||
action: () => {
|
||||
onToggleMode(path, 'suspended')
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: canWrite ? 'Edit' : 'View',
|
||||
icon: canWrite ? Pen : Eye,
|
||||
action: () => {
|
||||
gcpTriggerEditor?.openEdit(path, is_flow)
|
||||
}
|
||||
},
|
||||
...(isDeployable('trigger', path, deployUiSettings)
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer?.openDrawer(path, 'trigger', {
|
||||
triggers: {
|
||||
kind: 'gcp'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Audit logs',
|
||||
icon: Eye,
|
||||
href: `${base}/audit_logs?resource=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
action: () => {
|
||||
shareModal?.openDrawer(path, 'gcp_trigger')
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
type: 'delete',
|
||||
icon: Trash,
|
||||
disabled: !canWrite,
|
||||
action: async () => {
|
||||
isDeleting = false
|
||||
deleteSubscription = false
|
||||
subscriptionToDelete = subscription_id
|
||||
currentTopic = topic_id
|
||||
deleteSubscriptionCallback = async () => {
|
||||
const requestBody = { subscription_id, project_id }
|
||||
const message = gcp_resource_path
|
||||
? await GcpTriggerService.deleteGcpSubscription({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: gcp_resource_path,
|
||||
requestBody
|
||||
})
|
||||
: await GcpTriggerService.deleteGcpSubscriptionWithDefaultCredentials({
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody
|
||||
})
|
||||
sendUserToast(message)
|
||||
}
|
||||
deleteGcpTriggerCallback = async () => {
|
||||
const message = await GcpTriggerService.deleteGcpTrigger({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path
|
||||
})
|
||||
sendUserToast(message)
|
||||
loadTriggers()
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-between items-baseline">
|
||||
<div
|
||||
class="flex flex-wrap text-[0.7em] text-primary gap-1 items-center justify-end truncate pr-2"
|
||||
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
|
||||
class="truncate">{edited_by ? 'the ' : ''}{displayDate(edited_at)}</div
|
||||
></div
|
||||
></div
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<NoItemFound />
|
||||
{/if}
|
||||
</div>
|
||||
{#if items && items?.length > 15 && nbDisplayed < items.length}
|
||||
<span class="text-xs"
|
||||
>{nbDisplayed} items out of {items.length}
|
||||
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
|
||||
>
|
||||
{/if}
|
||||
</CenteredPage>
|
||||
|
||||
<ShareModal
|
||||
bind:this={shareModal}
|
||||
on:change={() => {
|
||||
loadTriggers()
|
||||
}}
|
||||
/>
|
||||
<TriggerList triggerKind="gcp" />
|
||||
|
||||
@@ -1,595 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { run } from 'svelte/legacy'
|
||||
|
||||
import {
|
||||
KafkaTriggerService,
|
||||
WorkspaceService,
|
||||
type KafkaTrigger,
|
||||
type TriggerMode,
|
||||
type WorkspaceDeployUISettings
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
canWrite,
|
||||
displayDate,
|
||||
getLocalSetting,
|
||||
sendUserToast,
|
||||
storeLocalSetting,
|
||||
removeTriggerKindIfUnused
|
||||
} from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { base } from '$app/paths'
|
||||
import { page } from '$app/stores'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import {
|
||||
userStore,
|
||||
workspaceStore,
|
||||
userWorkspaces,
|
||||
enterpriseLicense,
|
||||
usedTriggerKinds
|
||||
} from '$lib/stores'
|
||||
import { Code, Eye, Pen, Plus, Shield, Trash, Circle, FileUp, Pause } from 'lucide-svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import ListFilters from '$lib/components/home/ListFilters.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import KafkaIcon from '$lib/components/icons/KafkaIcon.svelte'
|
||||
import KafkaTriggerEditor from '$lib/components/triggers/kafka/KafkaTriggerEditor.svelte'
|
||||
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
|
||||
|
||||
type TriggerW = KafkaTrigger & { canWrite: boolean }
|
||||
|
||||
let triggers: TriggerW[] = $state([])
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
let loading = $state(true)
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
async function loadTriggers(): Promise<void> {
|
||||
triggers = (
|
||||
await KafkaTriggerService.listKafkaTriggers({
|
||||
workspace: $workspaceStore!,
|
||||
includeDraftOnly: true
|
||||
})
|
||||
).map((x) => {
|
||||
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
|
||||
})
|
||||
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'kafka', $usedTriggerKinds)
|
||||
loading = false
|
||||
}
|
||||
|
||||
let interval = setInterval(async () => {
|
||||
try {
|
||||
const newTriggers = await KafkaTriggerService.listKafkaTriggers({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
for (let i = 0; i < triggers.length; i++) {
|
||||
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
|
||||
if (newTrigger) {
|
||||
triggers[i] = {
|
||||
...triggers[i],
|
||||
error: newTrigger.error,
|
||||
last_server_ping: newTrigger.last_server_ping,
|
||||
mode: newTrigger.mode,
|
||||
server_id: newTrigger.server_id
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(interval)
|
||||
})
|
||||
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
|
||||
let committed = false
|
||||
try {
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
KafkaTriggerService.setKafkaTriggerMode({
|
||||
path,
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { mode, force }
|
||||
}),
|
||||
'Kafka trigger'
|
||||
)
|
||||
committed = ok
|
||||
if (ok) loadTriggers()
|
||||
} catch (err) {
|
||||
sendUserToast(
|
||||
`Cannot ` +
|
||||
(mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend') +
|
||||
` Kafka trigger: ${err.body}`,
|
||||
true
|
||||
)
|
||||
loadTriggers()
|
||||
}
|
||||
return committed
|
||||
}
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore && $userStore) {
|
||||
loadTriggers()
|
||||
}
|
||||
})
|
||||
let kafkaTriggerEditor: KafkaTriggerEditor | undefined = $state()
|
||||
|
||||
let hashHandled = false
|
||||
$effect(() => {
|
||||
if (!hashHandled && triggers.length > 0 && kafkaTriggerEditor) {
|
||||
let hash = $page.url.hash
|
||||
if (hash.length > 1) {
|
||||
let path = hash.slice(1)
|
||||
let trigger = triggers.find((t) => t.path === path)
|
||||
if (trigger) {
|
||||
hashHandled = true
|
||||
kafkaTriggerEditor?.openEdit(path, trigger.is_flow)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let filteredItems: (TriggerW & { marked?: any })[] | undefined = $state([])
|
||||
let items: typeof filteredItems | undefined = $state([])
|
||||
let preFilteredItems: typeof filteredItems | undefined = $state([])
|
||||
let filter = $state('')
|
||||
let ownerFilter: string | undefined = $state(undefined)
|
||||
let nbDisplayed = $state(15)
|
||||
|
||||
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
|
||||
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
|
||||
let selectedFilterKind = $state(
|
||||
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
|
||||
)
|
||||
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
|
||||
|
||||
run(() => {
|
||||
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
|
||||
})
|
||||
run(() => {
|
||||
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
|
||||
})
|
||||
|
||||
function filterItemsPathsBaseOnUserFilters(
|
||||
item: TriggerW,
|
||||
selectedFilterKind: 'trigger' | 'script_flow',
|
||||
filterUserFolders: boolean
|
||||
) {
|
||||
if ($workspaceStore == 'admins') return true
|
||||
if (filterUserFolders) {
|
||||
if (selectedFilterKind === 'trigger') {
|
||||
return (
|
||||
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
!item.script_path.startsWith('u/') ||
|
||||
item.script_path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
run(() => {
|
||||
preFilteredItems =
|
||||
ownerFilter != undefined
|
||||
? selectedFilterKind === 'trigger'
|
||||
? triggers?.filter(
|
||||
(x) =>
|
||||
x.path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter(
|
||||
(x) =>
|
||||
x.script_path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter((x) =>
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
})
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore) {
|
||||
ownerFilter = undefined
|
||||
}
|
||||
})
|
||||
|
||||
let owners = $derived(
|
||||
selectedFilterKind === 'trigger'
|
||||
? Array.from(
|
||||
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
: Array.from(
|
||||
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
)
|
||||
|
||||
run(() => {
|
||||
items = filter !== '' ? filteredItems : preFilteredItems
|
||||
})
|
||||
|
||||
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
|
||||
setQuery(
|
||||
TRIGGER_PATH_KIND_FILTER_SETTING,
|
||||
selectedFilterKind,
|
||||
window.location.hash || undefined
|
||||
).then(() => {
|
||||
setQuery(
|
||||
FILTER_USER_FOLDER_SETTING_NAME,
|
||||
String(filterUserFolders),
|
||||
window.location.hash || undefined
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function loadQueryFilters() {
|
||||
let url = new URL(window.location.href)
|
||||
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
|
||||
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
|
||||
if (queryFilterKind) {
|
||||
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
|
||||
}
|
||||
if (queryFilterUserFolders) {
|
||||
filterUserFolders = queryFilterUserFolders == 'true'
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadQueryFilters()
|
||||
})
|
||||
|
||||
run(() => {
|
||||
updateQueryFilters(selectedFilterKind, filterUserFolders)
|
||||
})
|
||||
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
|
||||
</script>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<KafkaTriggerEditor onUpdate={loadTriggers} bind:this={kafkaTriggerEditor} />
|
||||
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={preFilteredItems}
|
||||
bind:filteredItems
|
||||
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
|
||||
/>
|
||||
|
||||
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers}
|
||||
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
|
||||
<p class="font-bold">Unauthorized</p>
|
||||
<p>Page not available for operators</p>
|
||||
</div>
|
||||
{:else}
|
||||
<CenteredPage>
|
||||
<PageHeader
|
||||
title="Kafka triggers"
|
||||
tooltip="Windmill can consume kafka events and trigger scripts or flows based on them."
|
||||
>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => kafkaTriggerEditor?.openNew(false)}
|
||||
>
|
||||
New Kafka trigger
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{#if isCloudHosted()}
|
||||
<Alert title="Not compatible with multi-tenant cloud" type="warning">
|
||||
Kafka triggers are disabled in the multi-tenant cloud.
|
||||
</Alert>
|
||||
<div class="py-4"></div>
|
||||
{/if}
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<div class="w-full pb-4 pt-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search Kafka triggers"
|
||||
bind:value={filter}
|
||||
class="search-item"
|
||||
/>
|
||||
<div class="flex flex-row items-center gap-2 mt-6">
|
||||
<div class="text-sm shrink-0"> Filter by path of </div>
|
||||
<ToggleButtonGroup bind:selected={selectedFilterKind}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="trigger" label="Kafka trigger" icon={KafkaIcon} {item} />
|
||||
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
|
||||
|
||||
<div class="flex flex-row items-center justify-end gap-4">
|
||||
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
|
||||
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
|
||||
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={filterUserFolders}
|
||||
options={{ right: `Only u/${$userStore.username} and f/*` }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if loading}
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[6], 0.4]} />
|
||||
{/each}
|
||||
{:else if !triggers?.length}
|
||||
<EmptyState
|
||||
icon={KafkaIcon}
|
||||
title="No Kafka triggers yet"
|
||||
description="Windmill can consume Kafka events and trigger scripts or flows based on them."
|
||||
action={{
|
||||
label: 'Add a Kafka trigger',
|
||||
icon: Plus,
|
||||
onClick: () => kafkaTriggerEditor?.openNew(false),
|
||||
aiId: 'kafka-triggers-empty-add',
|
||||
aiDescription: 'Add Kafka trigger'
|
||||
}}
|
||||
/>
|
||||
{:else if items?.length}
|
||||
<div class="border rounded-md divide-y">
|
||||
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, kafka_resource_path, topics, extra_perms, canWrite, marked, server_id, error, last_server_ping, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
|
||||
{@const hasDraft =
|
||||
getLocalDraftHint($workspaceStore, 'trigger_kafka', path) ?? is_draft}
|
||||
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
|
||||
{@const ping = last_server_ping ? new Date(last_server_ping) : undefined}
|
||||
{@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000}
|
||||
{@const effectiveMode = draft_only ? 'disabled' : mode}
|
||||
{@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'}
|
||||
|
||||
<div
|
||||
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
|
||||
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
|
||||
>
|
||||
<div class="w-full flex gap-5 items-center">
|
||||
<RowIcon kind={is_flow ? 'flow' : 'script'} />
|
||||
|
||||
<a
|
||||
href="#{path}"
|
||||
onclick={() => kafkaTriggerEditor?.openEdit(path, is_flow)}
|
||||
class="min-w-0 grow hover:underline decoration-gray-400"
|
||||
>
|
||||
<div
|
||||
class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate"
|
||||
>
|
||||
{#if marked}
|
||||
<span class="text-xs">
|
||||
{@html marked}
|
||||
</span>
|
||||
{:else}
|
||||
{kafka_resource_path} - {topics.join(', ')}
|
||||
{/if}{hasDraft ? '*' : ''}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left font-light">
|
||||
{path}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left font-light">
|
||||
runnable: {script_path}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="hidden lg:flex flex-row gap-1 items-center">
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
{#if labels?.length}
|
||||
{#each labels as label}
|
||||
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="w-10">
|
||||
{#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle
|
||||
class="text-red-600 animate-ping absolute inline-flex fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
{#if enabled}
|
||||
{#if !server_id}
|
||||
Consumer is starting...
|
||||
{:else}
|
||||
Consumer is not connected{error ? ': ' + error : ''}
|
||||
{/if}
|
||||
{:else}
|
||||
Consumer was disabled because of an error: {error}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else if enabled}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle
|
||||
class="text-green-600 relative inline-flex fill-current"
|
||||
size={12}
|
||||
/>
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div> Consumer is connected </div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
|
||||
<DraftBadge {draft_only} is_draft={hasDraft} />
|
||||
<TriggerModeToggle
|
||||
disabled={draft_only}
|
||||
title={draft_only
|
||||
? 'Draft only: deploy the trigger to enable it'
|
||||
: hasDraft
|
||||
? 'Enables/disables the deployed trigger; the draft is not affected'
|
||||
: undefined}
|
||||
onToggleMode={(newMode) => onToggleMode(path, newMode)}
|
||||
triggerMode={effectiveMode}
|
||||
includeModalConfig={{
|
||||
triggerPath: path,
|
||||
triggerKind: 'kafka',
|
||||
runnableConfig: {
|
||||
path: script_path,
|
||||
kind: is_flow ? 'flow' : 'script',
|
||||
retry,
|
||||
errorHandlerPath: error_handler_path,
|
||||
errorHandlerArgs: error_handler_args
|
||||
}
|
||||
}}
|
||||
{canWrite}
|
||||
hideToggleLabels
|
||||
hideDropdown
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center justify-end">
|
||||
<Button
|
||||
on:click={() => kafkaTriggerEditor?.openEdit(path, is_flow)}
|
||||
unifiedSize="md"
|
||||
startIcon={canWrite
|
||||
? { icon: Pen }
|
||||
: {
|
||||
icon: Eye
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
{canWrite ? 'Edit' : 'View'}
|
||||
</Button>
|
||||
<Dropdown
|
||||
size="md"
|
||||
items={[
|
||||
{
|
||||
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
|
||||
icon: Eye,
|
||||
action: () => {
|
||||
goto(href)
|
||||
}
|
||||
},
|
||||
...(canWrite && !draft_only && mode !== 'suspended'
|
||||
? [
|
||||
{
|
||||
displayName: 'Suspend job execution',
|
||||
icon: Pause,
|
||||
action: () => {
|
||||
onToggleMode(path, 'suspended')
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
type: 'delete',
|
||||
icon: Trash,
|
||||
disabled: !canWrite,
|
||||
action: async () => {
|
||||
await KafkaTriggerService.deleteKafkaTrigger({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path
|
||||
})
|
||||
loadTriggers()
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: canWrite ? 'Edit' : 'View',
|
||||
icon: canWrite ? Pen : Eye,
|
||||
action: () => {
|
||||
kafkaTriggerEditor?.openEdit(path, is_flow)
|
||||
}
|
||||
},
|
||||
...(isDeployable('trigger', path, deployUiSettings)
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer?.openDrawer(path, 'trigger', {
|
||||
triggers: {
|
||||
kind: 'kafka'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Audit logs',
|
||||
icon: Eye,
|
||||
href: `${base}/audit_logs?resource=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
action: () => {
|
||||
shareModal?.openDrawer(path, 'kafka_trigger')
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-between items-baseline">
|
||||
<div
|
||||
class="flex flex-wrap text-2xs font-normal text-secondary gap-1 items-center justify-end truncate pr-2"
|
||||
>{#if edited_by}<div class="truncate">Edited by {edited_by}</div>{/if}<div
|
||||
class="truncate">{edited_by ? 'the ' : ''}{displayDate(edited_at)}</div
|
||||
></div
|
||||
></div
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<NoItemFound />
|
||||
{/if}
|
||||
</div>
|
||||
{#if items && items?.length > 15 && nbDisplayed < items.length}
|
||||
<span class="text-xs"
|
||||
>{nbDisplayed} items out of {items.length}
|
||||
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
|
||||
>
|
||||
{/if}
|
||||
</CenteredPage>
|
||||
{/if}
|
||||
|
||||
<ShareModal
|
||||
bind:this={shareModal}
|
||||
on:change={() => {
|
||||
loadTriggers()
|
||||
}}
|
||||
/>
|
||||
<TriggerList triggerKind="kafka" />
|
||||
|
||||
@@ -1,570 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { run } from 'svelte/legacy'
|
||||
|
||||
import {
|
||||
MqttTriggerService,
|
||||
WorkspaceService,
|
||||
type MqttTrigger,
|
||||
type TriggerMode,
|
||||
type WorkspaceDeployUISettings
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
canWrite,
|
||||
capitalize,
|
||||
displayDate,
|
||||
getLocalSetting,
|
||||
sendUserToast,
|
||||
storeLocalSetting,
|
||||
removeTriggerKindIfUnused
|
||||
} from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { base } from '$app/paths'
|
||||
import { page } from '$app/stores'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { enterpriseLicense, usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
|
||||
import { Code, Eye, Pen, Plus, Shield, Trash, Circle, FileUp, Pause } from 'lucide-svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import ListFilters from '$lib/components/home/ListFilters.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import MqttTriggerEditor from '$lib/components/triggers/mqtt/MqttTriggerEditor.svelte'
|
||||
import MqttIcon from '$lib/components/icons/MqttIcon.svelte'
|
||||
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
|
||||
|
||||
type TriggerM = MqttTrigger & { canWrite: boolean }
|
||||
|
||||
let triggers: TriggerM[] = $state([])
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
let loading = $state(true)
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
async function loadTriggers(): Promise<void> {
|
||||
triggers = (
|
||||
await MqttTriggerService.listMqttTriggers({
|
||||
workspace: $workspaceStore!,
|
||||
includeDraftOnly: true
|
||||
})
|
||||
).map((x) => {
|
||||
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
|
||||
})
|
||||
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'mqtt', $usedTriggerKinds)
|
||||
loading = false
|
||||
}
|
||||
|
||||
let interval = setInterval(async () => {
|
||||
try {
|
||||
const newTriggers = await MqttTriggerService.listMqttTriggers({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
for (let i = 0; i < triggers.length; i++) {
|
||||
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
|
||||
if (newTrigger) {
|
||||
triggers[i] = {
|
||||
...triggers[i],
|
||||
error: newTrigger.error,
|
||||
last_server_ping: newTrigger.last_server_ping,
|
||||
mode: newTrigger.mode,
|
||||
server_id: newTrigger.server_id
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(interval)
|
||||
})
|
||||
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
|
||||
let committed = false
|
||||
try {
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
MqttTriggerService.setMqttTriggerMode({
|
||||
path,
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { mode, force }
|
||||
}),
|
||||
'MQTT trigger'
|
||||
)
|
||||
if (ok) {
|
||||
sendUserToast(`${capitalize(mode)} MQTT trigger ${path}`)
|
||||
loadTriggers()
|
||||
}
|
||||
committed = ok
|
||||
} catch (err) {
|
||||
sendUserToast(
|
||||
`Cannot ${mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend'} mqtt trigger: ${err.body}`,
|
||||
true
|
||||
)
|
||||
loadTriggers()
|
||||
}
|
||||
return committed
|
||||
}
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore && $userStore) {
|
||||
loadTriggers()
|
||||
}
|
||||
})
|
||||
let mqttTriggerEditor: MqttTriggerEditor | undefined = $state()
|
||||
|
||||
let hashHandled = false
|
||||
$effect(() => {
|
||||
if (!hashHandled && triggers.length > 0 && mqttTriggerEditor) {
|
||||
let hash = $page.url.hash
|
||||
if (hash.length > 1) {
|
||||
let path = hash.slice(1)
|
||||
let trigger = triggers.find((t) => t.path === path)
|
||||
if (trigger) {
|
||||
hashHandled = true
|
||||
mqttTriggerEditor?.openEdit(path, trigger.is_flow)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let filteredItems: (TriggerM & { marked?: any })[] | undefined = $state([])
|
||||
let items: typeof filteredItems | undefined = $state([])
|
||||
let preFilteredItems: typeof filteredItems | undefined = $state([])
|
||||
let filter = $state('')
|
||||
let ownerFilter: string | undefined = $state(undefined)
|
||||
let nbDisplayed = $state(15)
|
||||
|
||||
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
|
||||
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
|
||||
let selectedFilterKind = $state(
|
||||
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
|
||||
)
|
||||
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
|
||||
|
||||
run(() => {
|
||||
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
|
||||
})
|
||||
run(() => {
|
||||
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
|
||||
})
|
||||
|
||||
function filterItemsPathsBaseOnUserFilters(
|
||||
item: TriggerM,
|
||||
selectedFilterKind: 'trigger' | 'script_flow',
|
||||
filterUserFolders: boolean
|
||||
) {
|
||||
if ($workspaceStore == 'admins') return true
|
||||
if (filterUserFolders) {
|
||||
if (selectedFilterKind === 'trigger') {
|
||||
return (
|
||||
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
!item.script_path.startsWith('u/') ||
|
||||
item.script_path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
run(() => {
|
||||
preFilteredItems =
|
||||
ownerFilter != undefined
|
||||
? selectedFilterKind === 'trigger'
|
||||
? triggers?.filter(
|
||||
(x) =>
|
||||
x.path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter(
|
||||
(x) =>
|
||||
x.script_path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter((x) =>
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
})
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore) {
|
||||
ownerFilter = undefined
|
||||
}
|
||||
})
|
||||
|
||||
let owners = $derived(
|
||||
selectedFilterKind === 'trigger'
|
||||
? Array.from(
|
||||
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
: Array.from(
|
||||
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
)
|
||||
|
||||
run(() => {
|
||||
items = filter !== '' ? filteredItems : preFilteredItems
|
||||
})
|
||||
|
||||
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
|
||||
setQuery(
|
||||
TRIGGER_PATH_KIND_FILTER_SETTING,
|
||||
selectedFilterKind,
|
||||
window.location.hash || undefined
|
||||
).then(() => {
|
||||
setQuery(
|
||||
FILTER_USER_FOLDER_SETTING_NAME,
|
||||
String(filterUserFolders),
|
||||
window.location.hash || undefined
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function loadQueryFilters() {
|
||||
let url = new URL(window.location.href)
|
||||
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
|
||||
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
|
||||
if (queryFilterKind) {
|
||||
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
|
||||
}
|
||||
if (queryFilterUserFolders) {
|
||||
filterUserFolders = queryFilterUserFolders == 'true'
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadQueryFilters()
|
||||
})
|
||||
|
||||
run(() => {
|
||||
updateQueryFilters(selectedFilterKind, filterUserFolders)
|
||||
})
|
||||
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
|
||||
</script>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<MqttTriggerEditor onUpdate={loadTriggers} bind:this={mqttTriggerEditor} />
|
||||
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={preFilteredItems}
|
||||
bind:filteredItems
|
||||
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
|
||||
/>
|
||||
|
||||
<CenteredPage>
|
||||
<PageHeader
|
||||
title="MQTT triggers"
|
||||
tooltip="Windmill can connect to an MQTT broker, subscribe to specific topics, and trigger scripts or flows based on those topics."
|
||||
>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => mqttTriggerEditor?.openNew(false)}
|
||||
>
|
||||
New MQTT trigger
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{#if isCloudHosted()}
|
||||
<Alert title="Not compatible with multi-tenant cloud" type="warning">
|
||||
MQTT triggers are disabled in the multi-tenant cloud.
|
||||
</Alert>
|
||||
<div class="py-4"></div>
|
||||
{/if}
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<div class="w-full pb-4 pt-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search MQTT triggers"
|
||||
bind:value={filter}
|
||||
class="search-item"
|
||||
/>
|
||||
<div class="flex flex-row items-center gap-2 mt-6">
|
||||
<div class="text-sm shrink-0"> Filter by path of </div>
|
||||
<ToggleButtonGroup bind:selected={selectedFilterKind}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="trigger" label="MQTT trigger" icon={MqttIcon} {item} />
|
||||
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
|
||||
|
||||
<div class="flex flex-row items-center justify-end gap-4">
|
||||
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
|
||||
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
|
||||
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={filterUserFolders}
|
||||
options={{ right: `Only u/${$userStore.username} and f/*` }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if loading}
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[6], 0.4]} />
|
||||
{/each}
|
||||
{:else if !triggers?.length}
|
||||
<EmptyState
|
||||
icon={MqttIcon}
|
||||
title="No MQTT triggers yet"
|
||||
description="Windmill can connect to an MQTT broker, subscribe to specific topics, and trigger scripts or flows based on those topics."
|
||||
action={{
|
||||
label: 'Add an MQTT trigger',
|
||||
icon: Plus,
|
||||
onClick: () => mqttTriggerEditor?.openNew(false),
|
||||
aiId: 'mqtt-triggers-empty-add',
|
||||
aiDescription: 'Add MQTT trigger'
|
||||
}}
|
||||
/>
|
||||
{:else if items?.length}
|
||||
<div class="border rounded-md divide-y">
|
||||
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, extra_perms, canWrite, error, last_server_ping, server_id, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
|
||||
{@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_mqtt', path) ?? is_draft}
|
||||
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
|
||||
{@const ping = last_server_ping ? new Date(last_server_ping) : undefined}
|
||||
{@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000}
|
||||
{@const effectiveMode = draft_only ? 'disabled' : mode}
|
||||
{@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'}
|
||||
|
||||
<div
|
||||
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
|
||||
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
|
||||
>
|
||||
<div class="w-full flex gap-5 items-center">
|
||||
<RowIcon kind={is_flow ? 'flow' : 'script'} />
|
||||
|
||||
<a
|
||||
href="#{path}"
|
||||
onclick={() => mqttTriggerEditor?.openEdit(path, is_flow)}
|
||||
class="min-w-0 grow hover:underline decoration-gray-400"
|
||||
>
|
||||
<div class="text-emphasis font-semibold text-xs truncate text-left">
|
||||
{path}{hasDraft ? '*' : ''}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left font-light">
|
||||
runnable: {script_path}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="hidden lg:flex flex-row gap-1 items-center">
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
{#if labels?.length}
|
||||
{#each labels as label}
|
||||
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="w-10">
|
||||
{#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle
|
||||
class="text-red-600 animate-ping absolute inline-flex fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
{#if enabled}
|
||||
{#if !server_id}
|
||||
MQTT trigger is starting...
|
||||
{:else}
|
||||
MQTT trigger is not connected{error ? ': ' + error : ''}
|
||||
{/if}
|
||||
{:else}
|
||||
MQTT trigger was disabled because of an error: {error}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else if enabled}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle class="text-green-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
MQTT trigger is connected{!server_id ? ' (shutting down...)' : ''}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
|
||||
<DraftBadge {draft_only} is_draft={hasDraft} />
|
||||
<TriggerModeToggle
|
||||
disabled={draft_only}
|
||||
title={draft_only
|
||||
? 'Draft only: deploy the trigger to enable it'
|
||||
: hasDraft
|
||||
? 'Enables/disables the deployed trigger; the draft is not affected'
|
||||
: undefined}
|
||||
onToggleMode={(newMode) => onToggleMode(path, newMode)}
|
||||
triggerMode={effectiveMode}
|
||||
includeModalConfig={{
|
||||
triggerPath: path,
|
||||
triggerKind: 'mqtt',
|
||||
runnableConfig: {
|
||||
path: script_path,
|
||||
kind: is_flow ? 'flow' : 'script',
|
||||
retry,
|
||||
errorHandlerPath: error_handler_path,
|
||||
errorHandlerArgs: error_handler_args
|
||||
}
|
||||
}}
|
||||
{canWrite}
|
||||
hideToggleLabels
|
||||
hideDropdown
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center justify-end">
|
||||
<Button
|
||||
on:click={() => mqttTriggerEditor?.openEdit(path, is_flow)}
|
||||
unifiedSize="md"
|
||||
startIcon={canWrite
|
||||
? { icon: Pen }
|
||||
: {
|
||||
icon: Eye
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
{canWrite ? 'Edit' : 'View'}
|
||||
</Button>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
|
||||
icon: Eye,
|
||||
action: () => {
|
||||
goto(href)
|
||||
}
|
||||
},
|
||||
...(canWrite && !draft_only && mode !== 'suspended'
|
||||
? [
|
||||
{
|
||||
displayName: 'Suspend job execution',
|
||||
icon: Pause,
|
||||
action: () => {
|
||||
onToggleMode(path, 'suspended')
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: canWrite ? 'Edit' : 'View',
|
||||
icon: canWrite ? Pen : Eye,
|
||||
action: () => {
|
||||
mqttTriggerEditor?.openEdit(path, is_flow)
|
||||
}
|
||||
},
|
||||
...(isDeployable('trigger', path, deployUiSettings)
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer?.openDrawer(path, 'trigger', {
|
||||
triggers: {
|
||||
kind: 'mqtt'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Audit logs',
|
||||
icon: Eye,
|
||||
href: `${base}/audit_logs?resource=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
action: () => {
|
||||
shareModal?.openDrawer(path, 'mqtt_trigger')
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
type: 'delete',
|
||||
icon: Trash,
|
||||
disabled: !canWrite,
|
||||
action: async () => {
|
||||
await MqttTriggerService.deleteMqttTrigger({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path
|
||||
})
|
||||
loadTriggers()
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-between items-baseline">
|
||||
<div
|
||||
class="flex flex-wrap text-[0.7em] text-primary gap-1 items-center justify-end truncate pr-2"
|
||||
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
|
||||
class="truncate">{edited_by ? 'the ' : ''}{displayDate(edited_at)}</div
|
||||
></div
|
||||
></div
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<NoItemFound />
|
||||
{/if}
|
||||
</div>
|
||||
{#if items && items?.length > 15 && nbDisplayed < items.length}
|
||||
<span class="text-xs"
|
||||
>{nbDisplayed} items out of {items.length}
|
||||
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
|
||||
>
|
||||
{/if}
|
||||
</CenteredPage>
|
||||
|
||||
<ShareModal
|
||||
bind:this={shareModal}
|
||||
on:change={() => {
|
||||
loadTriggers()
|
||||
}}
|
||||
/>
|
||||
<TriggerList triggerKind="mqtt" />
|
||||
|
||||
@@ -1,592 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { run } from 'svelte/legacy'
|
||||
|
||||
import {
|
||||
NatsTriggerService,
|
||||
WorkspaceService,
|
||||
type NatsTrigger,
|
||||
type TriggerMode,
|
||||
type WorkspaceDeployUISettings
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
canWrite,
|
||||
displayDate,
|
||||
getLocalSetting,
|
||||
sendUserToast,
|
||||
storeLocalSetting,
|
||||
removeTriggerKindIfUnused
|
||||
} from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { base } from '$app/paths'
|
||||
import { page } from '$app/stores'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import {
|
||||
userStore,
|
||||
workspaceStore,
|
||||
userWorkspaces,
|
||||
enterpriseLicense,
|
||||
usedTriggerKinds
|
||||
} from '$lib/stores'
|
||||
import { Code, Eye, Pen, Plus, Shield, Trash, Circle, FileUp, Pause } from 'lucide-svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import ListFilters from '$lib/components/home/ListFilters.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import NatsIcon from '$lib/components/icons/NatsIcon.svelte'
|
||||
import NatsTriggerEditor from '$lib/components/triggers/nats/NatsTriggerEditor.svelte'
|
||||
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
|
||||
|
||||
type TriggerW = NatsTrigger & { canWrite: boolean }
|
||||
|
||||
let triggers: TriggerW[] = $state([])
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
let loading = $state(true)
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
async function loadTriggers(): Promise<void> {
|
||||
triggers = (
|
||||
await NatsTriggerService.listNatsTriggers({
|
||||
workspace: $workspaceStore!,
|
||||
includeDraftOnly: true
|
||||
})
|
||||
).map((x) => {
|
||||
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
|
||||
})
|
||||
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'nats', $usedTriggerKinds)
|
||||
loading = false
|
||||
}
|
||||
|
||||
let interval = setInterval(async () => {
|
||||
try {
|
||||
const newTriggers = await NatsTriggerService.listNatsTriggers({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
for (let i = 0; i < triggers.length; i++) {
|
||||
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
|
||||
if (newTrigger) {
|
||||
triggers[i] = {
|
||||
...triggers[i],
|
||||
error: newTrigger.error,
|
||||
last_server_ping: newTrigger.last_server_ping,
|
||||
mode: newTrigger.mode,
|
||||
server_id: newTrigger.server_id
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(interval)
|
||||
})
|
||||
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
|
||||
let committed = false
|
||||
try {
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
NatsTriggerService.setNatsTriggerMode({
|
||||
path,
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { mode, force }
|
||||
}),
|
||||
'NATS trigger'
|
||||
)
|
||||
committed = ok
|
||||
if (ok) loadTriggers()
|
||||
} catch (err) {
|
||||
sendUserToast(
|
||||
`Cannot ` +
|
||||
(mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend') +
|
||||
` NATS trigger: ${err.body}`,
|
||||
true
|
||||
)
|
||||
loadTriggers()
|
||||
}
|
||||
return committed
|
||||
}
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore && $userStore) {
|
||||
loadTriggers()
|
||||
}
|
||||
})
|
||||
let natsTriggerEditor: NatsTriggerEditor | undefined = $state()
|
||||
|
||||
let hashHandled = false
|
||||
$effect(() => {
|
||||
if (!hashHandled && triggers.length > 0 && natsTriggerEditor) {
|
||||
let hash = $page.url.hash
|
||||
if (hash.length > 1) {
|
||||
let path = hash.slice(1)
|
||||
let trigger = triggers.find((t) => t.path === path)
|
||||
if (trigger) {
|
||||
hashHandled = true
|
||||
natsTriggerEditor?.openEdit(path, trigger.is_flow)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let filteredItems: (TriggerW & { marked?: any })[] | undefined = $state([])
|
||||
let items: typeof filteredItems | undefined = $state([])
|
||||
let preFilteredItems: typeof filteredItems | undefined = $state([])
|
||||
let filter = $state('')
|
||||
let ownerFilter: string | undefined = $state(undefined)
|
||||
let nbDisplayed = $state(15)
|
||||
|
||||
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
|
||||
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
|
||||
let selectedFilterKind = $state(
|
||||
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
|
||||
)
|
||||
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
|
||||
|
||||
run(() => {
|
||||
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
|
||||
})
|
||||
run(() => {
|
||||
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
|
||||
})
|
||||
|
||||
function filterItemsPathsBaseOnUserFilters(
|
||||
item: TriggerW,
|
||||
selectedFilterKind: 'trigger' | 'script_flow',
|
||||
filterUserFolders: boolean
|
||||
) {
|
||||
if ($workspaceStore == 'admins') return true
|
||||
if (filterUserFolders) {
|
||||
if (selectedFilterKind === 'trigger') {
|
||||
return (
|
||||
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
!item.script_path.startsWith('u/') ||
|
||||
item.script_path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
run(() => {
|
||||
preFilteredItems =
|
||||
ownerFilter != undefined
|
||||
? selectedFilterKind === 'trigger'
|
||||
? triggers?.filter(
|
||||
(x) =>
|
||||
x.path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter(
|
||||
(x) =>
|
||||
x.script_path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter((x) =>
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
})
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore) {
|
||||
ownerFilter = undefined
|
||||
}
|
||||
})
|
||||
|
||||
let owners = $derived(
|
||||
selectedFilterKind === 'trigger'
|
||||
? Array.from(
|
||||
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
: Array.from(
|
||||
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
)
|
||||
|
||||
run(() => {
|
||||
items = filter !== '' ? filteredItems : preFilteredItems
|
||||
})
|
||||
|
||||
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
|
||||
setQuery(
|
||||
TRIGGER_PATH_KIND_FILTER_SETTING,
|
||||
selectedFilterKind,
|
||||
window.location.hash || undefined
|
||||
).then(() => {
|
||||
setQuery(
|
||||
FILTER_USER_FOLDER_SETTING_NAME,
|
||||
String(filterUserFolders),
|
||||
window.location.hash || undefined
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function loadQueryFilters() {
|
||||
let url = new URL(window.location.href)
|
||||
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
|
||||
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
|
||||
if (queryFilterKind) {
|
||||
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
|
||||
}
|
||||
if (queryFilterUserFolders) {
|
||||
filterUserFolders = queryFilterUserFolders == 'true'
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadQueryFilters()
|
||||
})
|
||||
|
||||
run(() => {
|
||||
updateQueryFilters(selectedFilterKind, filterUserFolders)
|
||||
})
|
||||
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
|
||||
</script>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<NatsTriggerEditor onUpdate={loadTriggers} bind:this={natsTriggerEditor} />
|
||||
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={preFilteredItems}
|
||||
bind:filteredItems
|
||||
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
|
||||
/>
|
||||
|
||||
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers}
|
||||
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
|
||||
<p class="font-bold">Unauthorized</p>
|
||||
<p>Page not available for operators</p>
|
||||
</div>
|
||||
{:else}
|
||||
<CenteredPage>
|
||||
<PageHeader
|
||||
title="NATS triggers"
|
||||
tooltip="Windmill can consume NATS events and trigger scripts or flows based on them."
|
||||
>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => natsTriggerEditor?.openNew(false)}
|
||||
>
|
||||
New NATS trigger
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{#if isCloudHosted()}
|
||||
<Alert title="Not compatible with multi-tenant cloud" type="warning">
|
||||
NATS triggers are disabled in the multi-tenant cloud.
|
||||
</Alert>
|
||||
<div class="py-4"></div>
|
||||
{/if}
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<div class="w-full pb-4 pt-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search NATS triggers"
|
||||
bind:value={filter}
|
||||
class="search-item"
|
||||
/>
|
||||
<div class="flex flex-row items-center gap-2 mt-2">
|
||||
<div class="text-xs font-semibold text-emphasis shrink-0"> Filter by path of </div>
|
||||
<ToggleButtonGroup bind:selected={selectedFilterKind}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="trigger" label="NATS trigger" icon={NatsIcon} {item} />
|
||||
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
|
||||
|
||||
<div class="flex flex-row items-center justify-end gap-4">
|
||||
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
|
||||
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
|
||||
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={filterUserFolders}
|
||||
options={{ right: `Only u/${$userStore.username} and f/*` }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if loading}
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[6], 0.4]} />
|
||||
{/each}
|
||||
{:else if !triggers?.length}
|
||||
<EmptyState
|
||||
icon={NatsIcon}
|
||||
title="No NATS triggers yet"
|
||||
description="Windmill can consume NATS events and trigger scripts or flows based on them."
|
||||
action={{
|
||||
label: 'Add a NATS trigger',
|
||||
icon: Plus,
|
||||
onClick: () => natsTriggerEditor?.openNew(false),
|
||||
aiId: 'nats-triggers-empty-add',
|
||||
aiDescription: 'Add NATS trigger'
|
||||
}}
|
||||
/>
|
||||
{:else if items?.length}
|
||||
<div class="border rounded-md divide-y">
|
||||
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, nats_resource_path, subjects, extra_perms, canWrite, marked, server_id, error, last_server_ping, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
|
||||
{@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_nats', path) ?? is_draft}
|
||||
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
|
||||
{@const ping = last_server_ping ? new Date(last_server_ping) : undefined}
|
||||
{@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000}
|
||||
{@const effectiveMode = draft_only ? 'disabled' : mode}
|
||||
{@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'}
|
||||
|
||||
<div
|
||||
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
|
||||
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
|
||||
>
|
||||
<div class="w-full flex gap-5 items-center">
|
||||
<RowIcon kind={is_flow ? 'flow' : 'script'} />
|
||||
|
||||
<a
|
||||
href="#{path}"
|
||||
onclick={() => natsTriggerEditor?.openEdit(path, is_flow)}
|
||||
class="min-w-0 grow hover:underline decoration-gray-400"
|
||||
>
|
||||
<div
|
||||
class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate"
|
||||
>
|
||||
{#if marked}
|
||||
<span class="text-xs">
|
||||
{@html marked}
|
||||
</span>
|
||||
{:else}
|
||||
{nats_resource_path} - {subjects.join(', ')}
|
||||
{/if}{hasDraft ? '*' : ''}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left font-light">
|
||||
{path}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left font-light">
|
||||
runnable: {script_path}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="hidden lg:flex flex-row gap-1 items-center">
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
{#if labels?.length}
|
||||
{#each labels as label}
|
||||
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="w-10">
|
||||
{#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle
|
||||
class="text-red-600 animate-ping absolute inline-flex fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
{#if enabled}
|
||||
{#if !server_id}
|
||||
Consumer is starting...
|
||||
{:else}
|
||||
Consumer is not connected{error ? ': ' + error : ''}
|
||||
{/if}
|
||||
{:else}
|
||||
Consumer was disabled because of an error: {error}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else if enabled}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle
|
||||
class="text-green-600 relative inline-flex fill-current"
|
||||
size={12}
|
||||
/>
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div> Consumer is connected </div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
|
||||
<DraftBadge {draft_only} is_draft={hasDraft} />
|
||||
<TriggerModeToggle
|
||||
disabled={draft_only}
|
||||
title={draft_only
|
||||
? 'Draft only: deploy the trigger to enable it'
|
||||
: hasDraft
|
||||
? 'Enables/disables the deployed trigger; the draft is not affected'
|
||||
: undefined}
|
||||
onToggleMode={(newMode) => onToggleMode(path, newMode)}
|
||||
triggerMode={effectiveMode}
|
||||
includeModalConfig={{
|
||||
triggerPath: path,
|
||||
triggerKind: 'nats',
|
||||
runnableConfig: {
|
||||
path: script_path,
|
||||
kind: is_flow ? 'flow' : 'script',
|
||||
retry,
|
||||
errorHandlerPath: error_handler_path,
|
||||
errorHandlerArgs: error_handler_args
|
||||
}
|
||||
}}
|
||||
{canWrite}
|
||||
hideToggleLabels
|
||||
hideDropdown
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center justify-end">
|
||||
<Button
|
||||
on:click={() => natsTriggerEditor?.openEdit(path, is_flow)}
|
||||
unifiedSize="md"
|
||||
startIcon={canWrite
|
||||
? { icon: Pen }
|
||||
: {
|
||||
icon: Eye
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
{canWrite ? 'Edit' : 'View'}
|
||||
</Button>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
|
||||
icon: Eye,
|
||||
action: () => {
|
||||
goto(href)
|
||||
}
|
||||
},
|
||||
...(canWrite && !draft_only && mode !== 'suspended'
|
||||
? [
|
||||
{
|
||||
displayName: 'Suspend job execution',
|
||||
icon: Pause,
|
||||
action: () => {
|
||||
onToggleMode(path, 'suspended')
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: canWrite ? 'Edit' : 'View',
|
||||
icon: canWrite ? Pen : Eye,
|
||||
action: () => {
|
||||
natsTriggerEditor?.openEdit(path, is_flow)
|
||||
}
|
||||
},
|
||||
...(isDeployable('trigger', path, deployUiSettings)
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer?.openDrawer(path, 'trigger', {
|
||||
triggers: {
|
||||
kind: 'nats'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Audit logs',
|
||||
icon: Eye,
|
||||
href: `${base}/audit_logs?resource=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
action: () => {
|
||||
shareModal?.openDrawer(path, 'nats_trigger')
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
type: 'delete',
|
||||
icon: Trash,
|
||||
disabled: !canWrite,
|
||||
action: async () => {
|
||||
await NatsTriggerService.deleteNatsTrigger({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path
|
||||
})
|
||||
loadTriggers()
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-between items-baseline">
|
||||
<div
|
||||
class="flex flex-wrap text-2xs text-secondary gap-1 items-center justify-end truncate pr-2"
|
||||
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
|
||||
class="truncate">{edited_by ? 'the ' : ''}{displayDate(edited_at)}</div
|
||||
></div
|
||||
></div
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<NoItemFound />
|
||||
{/if}
|
||||
</div>
|
||||
{#if items && items?.length > 15 && nbDisplayed < items.length}
|
||||
<span class="text-xs"
|
||||
>{nbDisplayed} items out of {items.length}
|
||||
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
|
||||
>
|
||||
{/if}
|
||||
</CenteredPage>
|
||||
{/if}
|
||||
|
||||
<ShareModal
|
||||
bind:this={shareModal}
|
||||
on:change={() => {
|
||||
loadTriggers()
|
||||
}}
|
||||
/>
|
||||
<TriggerList triggerKind="nats" />
|
||||
|
||||
@@ -1,674 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { run } from 'svelte/legacy'
|
||||
|
||||
import {
|
||||
PostgresTriggerService,
|
||||
WorkspaceService,
|
||||
type PostgresTrigger,
|
||||
type TriggerMode,
|
||||
type WorkspaceDeployUISettings
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
canWrite,
|
||||
displayDate,
|
||||
getLocalSetting,
|
||||
sendUserToast,
|
||||
storeLocalSetting,
|
||||
removeTriggerKindIfUnused,
|
||||
capitalize
|
||||
} from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { base } from '$app/paths'
|
||||
import { page } from '$app/stores'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { enterpriseLicense, usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
Code,
|
||||
Eye,
|
||||
Pen,
|
||||
Plus,
|
||||
Shield,
|
||||
Trash,
|
||||
Circle,
|
||||
Database,
|
||||
FileUp,
|
||||
Pause
|
||||
} from 'lucide-svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import ListFilters from '$lib/components/home/ListFilters.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import PostgresTriggerEditor from '$lib/components/triggers/postgres/PostgresTriggerEditor.svelte'
|
||||
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
|
||||
|
||||
type TriggerD = PostgresTrigger & { canWrite: boolean }
|
||||
|
||||
let triggers: TriggerD[] = $state([])
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
let loading = $state(true)
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
async function loadTriggers(): Promise<void> {
|
||||
triggers = (
|
||||
await PostgresTriggerService.listPostgresTriggers({
|
||||
workspace: $workspaceStore!,
|
||||
includeDraftOnly: true
|
||||
})
|
||||
).map((x) => {
|
||||
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
|
||||
})
|
||||
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'postgres', $usedTriggerKinds)
|
||||
loading = false
|
||||
}
|
||||
|
||||
let interval = setInterval(async () => {
|
||||
try {
|
||||
const newTriggers = await PostgresTriggerService.listPostgresTriggers({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
for (let i = 0; i < triggers.length; i++) {
|
||||
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
|
||||
if (newTrigger) {
|
||||
triggers[i] = {
|
||||
...triggers[i],
|
||||
error: newTrigger.error,
|
||||
last_server_ping: newTrigger.last_server_ping,
|
||||
mode: newTrigger.mode,
|
||||
server_id: newTrigger.server_id
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(interval)
|
||||
})
|
||||
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
|
||||
let committed = false
|
||||
try {
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
PostgresTriggerService.setPostgresTriggerMode({
|
||||
path,
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { mode, force }
|
||||
}),
|
||||
'postgres trigger'
|
||||
)
|
||||
if (ok) {
|
||||
sendUserToast(`${capitalize(mode)} postgres trigger ${path}`)
|
||||
loadTriggers()
|
||||
}
|
||||
committed = ok
|
||||
} catch (err) {
|
||||
sendUserToast(`Cannot change postgres trigger mode: ${err.body}`, true)
|
||||
loadTriggers()
|
||||
}
|
||||
return committed
|
||||
}
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore && $userStore) {
|
||||
loadTriggers()
|
||||
}
|
||||
})
|
||||
let postgresTriggerEditor: PostgresTriggerEditor | undefined = $state()
|
||||
|
||||
let hashHandled = false
|
||||
$effect(() => {
|
||||
if (!hashHandled && triggers.length > 0 && postgresTriggerEditor) {
|
||||
let hash = $page.url.hash
|
||||
if (hash.length > 1) {
|
||||
let path = hash.slice(1)
|
||||
let trigger = triggers.find((t) => t.path === path)
|
||||
if (trigger) {
|
||||
hashHandled = true
|
||||
postgresTriggerEditor?.openEdit(path, trigger.is_flow)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let filteredItems: (TriggerD & { marked?: any })[] | undefined = $state([])
|
||||
let items: typeof filteredItems | undefined = $state([])
|
||||
let preFilteredItems: typeof filteredItems | undefined = $state([])
|
||||
let filter = $state('')
|
||||
let ownerFilter: string | undefined = $state(undefined)
|
||||
let nbDisplayed = $state(15)
|
||||
|
||||
let isDeleting: boolean = $state(false)
|
||||
let deleteReplicationSlot: boolean = $state(false)
|
||||
let deletePublication: boolean = $state(false)
|
||||
let replicationSlotToDelete = $state('')
|
||||
let publicationToDelete = $state('')
|
||||
let deleteReplicationSlotCallback: (() => Promise<string>) | undefined = $state(undefined)
|
||||
let deletePublicationCallback: (() => Promise<string>) | undefined = $state(undefined)
|
||||
let deletePostgresTriggerCallback: (() => Promise<string>) | undefined = $state(undefined)
|
||||
|
||||
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
|
||||
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
|
||||
let selectedFilterKind = $state(
|
||||
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
|
||||
)
|
||||
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
|
||||
|
||||
run(() => {
|
||||
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
|
||||
})
|
||||
run(() => {
|
||||
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
|
||||
})
|
||||
|
||||
function filterItemsPathsBaseOnUserFilters(
|
||||
item: TriggerD,
|
||||
selectedFilterKind: 'trigger' | 'script_flow',
|
||||
filterUserFolders: boolean
|
||||
) {
|
||||
if ($workspaceStore == 'admins') return true
|
||||
if (filterUserFolders) {
|
||||
if (selectedFilterKind === 'trigger') {
|
||||
return (
|
||||
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
!item.script_path.startsWith('u/') ||
|
||||
item.script_path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
run(() => {
|
||||
preFilteredItems =
|
||||
ownerFilter != undefined
|
||||
? selectedFilterKind === 'trigger'
|
||||
? triggers?.filter(
|
||||
(x) =>
|
||||
x.path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter(
|
||||
(x) =>
|
||||
x.script_path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter((x) =>
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
})
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore) {
|
||||
ownerFilter = undefined
|
||||
}
|
||||
})
|
||||
|
||||
let owners = $derived(
|
||||
selectedFilterKind === 'trigger'
|
||||
? Array.from(
|
||||
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
: Array.from(
|
||||
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
)
|
||||
|
||||
run(() => {
|
||||
items = filter !== '' ? filteredItems : preFilteredItems
|
||||
})
|
||||
|
||||
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
|
||||
setQuery(
|
||||
TRIGGER_PATH_KIND_FILTER_SETTING,
|
||||
selectedFilterKind,
|
||||
window.location.hash || undefined
|
||||
).then(() => {
|
||||
setQuery(
|
||||
FILTER_USER_FOLDER_SETTING_NAME,
|
||||
String(filterUserFolders),
|
||||
window.location.hash || undefined
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function loadQueryFilters() {
|
||||
let url = new URL(window.location.href)
|
||||
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
|
||||
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
|
||||
if (queryFilterKind) {
|
||||
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
|
||||
}
|
||||
if (queryFilterUserFolders) {
|
||||
filterUserFolders = queryFilterUserFolders == 'true'
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadQueryFilters()
|
||||
})
|
||||
|
||||
run(() => {
|
||||
updateQueryFilters(selectedFilterKind, filterUserFolders)
|
||||
})
|
||||
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
|
||||
</script>
|
||||
|
||||
<ConfirmationModal
|
||||
open={Boolean(deletePostgresTriggerCallback)}
|
||||
title="Delete Postgres trigger"
|
||||
confirmationText="Remove"
|
||||
loading={isDeleting}
|
||||
on:canceled={() => {
|
||||
isDeleting = false
|
||||
deletePostgresTriggerCallback = undefined
|
||||
}}
|
||||
on:confirmed={async () => {
|
||||
if (deletePostgresTriggerCallback) {
|
||||
let msg: string
|
||||
isDeleting = true
|
||||
if (deleteReplicationSlot && deleteReplicationSlotCallback) {
|
||||
try {
|
||||
msg = await deleteReplicationSlotCallback()
|
||||
sendUserToast(msg)
|
||||
} catch (error) {
|
||||
isDeleting = false
|
||||
sendUserToast(error.body || error.message, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (deletePublication && deletePublicationCallback) {
|
||||
try {
|
||||
msg = await deletePublicationCallback()
|
||||
sendUserToast(msg)
|
||||
} catch (error) {
|
||||
isDeleting = false
|
||||
sendUserToast(error.body || error.message, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
msg = await deletePostgresTriggerCallback()
|
||||
sendUserToast(msg)
|
||||
}
|
||||
deletePostgresTriggerCallback = undefined
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
<span>Are you sure you want to remove this trigger?</span>
|
||||
|
||||
<Toggle
|
||||
options={{
|
||||
left: `Delete the associated replication slot named: ${replicationSlotToDelete} ?`
|
||||
}}
|
||||
bind:checked={deleteReplicationSlot}
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
options={{
|
||||
left: `Delete the associated publication named: ${publicationToDelete} ?`
|
||||
}}
|
||||
bind:checked={deletePublication}
|
||||
/>
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<PostgresTriggerEditor onUpdate={loadTriggers} bind:this={postgresTriggerEditor} />
|
||||
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={preFilteredItems}
|
||||
bind:filteredItems
|
||||
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
|
||||
/>
|
||||
|
||||
<CenteredPage>
|
||||
<PageHeader
|
||||
title="Postgres triggers"
|
||||
tooltip="Windmill enables real-time responsiveness by listening to specific database transactions—such as inserts, updates, and deletes—and automatically triggering scripts or workflows in response."
|
||||
>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => postgresTriggerEditor?.openNew(false)}
|
||||
>
|
||||
New Postgres trigger
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{#if isCloudHosted()}
|
||||
<Alert title="Not compatible with multi-tenant cloud" type="warning">
|
||||
Postgres triggers are disabled in the multi-tenant cloud.
|
||||
</Alert>
|
||||
<div class="py-4"></div>
|
||||
{/if}
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<div class="w-full pb-4 pt-6">
|
||||
<TextInput
|
||||
inputProps={{
|
||||
placeholder: 'Search Postgres triggers',
|
||||
class: 'search-item'
|
||||
}}
|
||||
bind:value={filter}
|
||||
/>
|
||||
<div class="flex flex-row items-center gap-2 mt-2">
|
||||
<div class="text-xs font-semibold text-emphasis shrink-0"> Filter by path of </div>
|
||||
<ToggleButtonGroup bind:selected={selectedFilterKind}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="trigger" label="Postgres trigger" icon={Database} {item} />
|
||||
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
|
||||
|
||||
<div class="flex flex-row items-center justify-end gap-4">
|
||||
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
|
||||
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
|
||||
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={filterUserFolders}
|
||||
options={{ right: `Only u/${$userStore.username} and f/*` }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if loading}
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[6], 0.4]} />
|
||||
{/each}
|
||||
{:else if !triggers?.length}
|
||||
<EmptyState
|
||||
icon={Database}
|
||||
title="No Postgres triggers yet"
|
||||
description="Windmill can listen to database transactions — inserts, updates and deletes — and trigger scripts or flows in response."
|
||||
action={{
|
||||
label: 'Add a Postgres trigger',
|
||||
icon: Plus,
|
||||
onClick: () => postgresTriggerEditor?.openNew(false),
|
||||
aiId: 'postgres-triggers-empty-add',
|
||||
aiDescription: 'Add Postgres trigger'
|
||||
}}
|
||||
/>
|
||||
{:else if items?.length}
|
||||
<div class="border rounded-md divide-y">
|
||||
{#each items.slice(0, nbDisplayed) as { postgres_resource_path, publication_name, replication_slot_name, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
|
||||
{@const hasDraft =
|
||||
getLocalDraftHint($workspaceStore, 'trigger_postgres', path) ?? is_draft}
|
||||
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
|
||||
{@const ping = new Date()}
|
||||
{@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000}
|
||||
{@const effectiveMode = draft_only ? 'disabled' : mode}
|
||||
{@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'}
|
||||
|
||||
<div
|
||||
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
|
||||
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
|
||||
>
|
||||
<div class="w-full flex gap-5 items-center">
|
||||
<RowIcon kind={is_flow ? 'flow' : 'script'} />
|
||||
|
||||
<a
|
||||
href="#{path}"
|
||||
onclick={() => postgresTriggerEditor?.openEdit(path, is_flow)}
|
||||
class="min-w-0 grow hover:underline decoration-gray-400"
|
||||
>
|
||||
<div class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate">
|
||||
{path}{hasDraft ? '*' : ''}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left font-light">
|
||||
{postgres_resource_path}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left font-light">
|
||||
runnable: {script_path}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="hidden lg:flex flex-row gap-1 items-center">
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
{#if labels?.length}
|
||||
{#each labels as label}
|
||||
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="w-10">
|
||||
{#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle
|
||||
class="text-red-600 animate-ping absolute inline-flex fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
{#if enabled}
|
||||
{#if !server_id}
|
||||
Postgres trigger is starting...
|
||||
{:else}
|
||||
Could not connect to database{error ? ': ' + error : ''}
|
||||
{/if}
|
||||
{:else}
|
||||
Disabled because of an error: {error}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else if enabled}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle class="text-green-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div> Connected to database{!server_id ? ' (shutting down...)' : ''}</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
|
||||
<DraftBadge {draft_only} is_draft={hasDraft} />
|
||||
<TriggerModeToggle
|
||||
disabled={draft_only}
|
||||
title={draft_only
|
||||
? 'Draft only: deploy the trigger to enable it'
|
||||
: hasDraft
|
||||
? 'Enables/disables the deployed trigger; the draft is not affected'
|
||||
: undefined}
|
||||
onToggleMode={(newMode) => onToggleMode(path, newMode)}
|
||||
triggerMode={effectiveMode}
|
||||
includeModalConfig={{
|
||||
triggerPath: path,
|
||||
triggerKind: 'postgres',
|
||||
runnableConfig: {
|
||||
path: script_path,
|
||||
kind: is_flow ? 'flow' : 'script',
|
||||
retry,
|
||||
errorHandlerPath: error_handler_path,
|
||||
errorHandlerArgs: error_handler_args
|
||||
}
|
||||
}}
|
||||
{canWrite}
|
||||
hideToggleLabels
|
||||
hideDropdown
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center justify-end">
|
||||
<Button
|
||||
on:click={() => postgresTriggerEditor?.openEdit(path, is_flow)}
|
||||
size="xs"
|
||||
startIcon={canWrite
|
||||
? { icon: Pen }
|
||||
: {
|
||||
icon: Eye
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
{canWrite ? 'Edit' : 'View'}
|
||||
</Button>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
|
||||
icon: Eye,
|
||||
action: () => {
|
||||
goto(href)
|
||||
}
|
||||
},
|
||||
...(canWrite && !draft_only && mode !== 'suspended'
|
||||
? [
|
||||
{
|
||||
displayName: 'Suspend job execution',
|
||||
icon: Pause,
|
||||
action: () => {
|
||||
onToggleMode(path, 'suspended')
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
type: 'delete',
|
||||
icon: Trash,
|
||||
disabled: !canWrite,
|
||||
action: async () => {
|
||||
publicationToDelete = publication_name
|
||||
replicationSlotToDelete = replication_slot_name
|
||||
isDeleting = false
|
||||
deletePublication = false
|
||||
deleteReplicationSlot = false
|
||||
deletePublicationCallback = async () => {
|
||||
return await PostgresTriggerService.deletePostgresPublication({
|
||||
workspace: $workspaceStore!,
|
||||
path: postgres_resource_path,
|
||||
publication: publication_name
|
||||
})
|
||||
}
|
||||
|
||||
deleteReplicationSlotCallback = async () => {
|
||||
return await PostgresTriggerService.deletePostgresReplicationSlot({
|
||||
workspace: $workspaceStore!,
|
||||
path: postgres_resource_path,
|
||||
requestBody: {
|
||||
name: replication_slot_name
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
deletePostgresTriggerCallback = async () => {
|
||||
const msg = await PostgresTriggerService.deletePostgresTrigger({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path
|
||||
})
|
||||
loadTriggers()
|
||||
return msg
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: canWrite ? 'Edit' : 'View',
|
||||
icon: canWrite ? Pen : Eye,
|
||||
action: () => {
|
||||
postgresTriggerEditor?.openEdit(path, is_flow)
|
||||
}
|
||||
},
|
||||
...(isDeployable('trigger', path, deployUiSettings)
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer?.openDrawer(path, 'trigger', {
|
||||
triggers: {
|
||||
kind: 'postgres'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Audit logs',
|
||||
icon: Eye,
|
||||
href: `${base}/audit_logs?resource=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
action: () => {
|
||||
shareModal?.openDrawer(path, 'postgres_trigger')
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-between items-baseline">
|
||||
<div
|
||||
class="flex flex-wrap text-[0.7em] text-primary gap-1 items-center justify-end truncate pr-2"
|
||||
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
|
||||
class="truncate">{edited_by ? 'the ' : ''}{displayDate(edited_at)}</div
|
||||
></div
|
||||
></div
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<NoItemFound />
|
||||
{/if}
|
||||
</div>
|
||||
{#if items && items?.length > 15 && nbDisplayed < items.length}
|
||||
<span class="text-xs"
|
||||
>{nbDisplayed} items out of {items.length}
|
||||
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
|
||||
>
|
||||
{/if}
|
||||
</CenteredPage>
|
||||
|
||||
<ShareModal
|
||||
bind:this={shareModal}
|
||||
on:change={() => {
|
||||
loadTriggers()
|
||||
}}
|
||||
/>
|
||||
<TriggerList triggerKind="postgres" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,592 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { run } from 'svelte/legacy'
|
||||
|
||||
import {
|
||||
HttpTriggerService,
|
||||
SettingService,
|
||||
WorkspaceService,
|
||||
type HttpTrigger,
|
||||
type TriggerMode,
|
||||
type WorkspaceDeployUISettings
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
canWrite,
|
||||
copyToClipboard,
|
||||
displayDate,
|
||||
getLocalSetting,
|
||||
storeLocalSetting,
|
||||
removeTriggerKindIfUnused,
|
||||
sendUserToast
|
||||
} from '$lib/utils'
|
||||
import { base } from '$app/paths'
|
||||
import { page } from '$app/stores'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import { Button, EmptyState, Skeleton } from '$lib/components/common'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import {
|
||||
userStore,
|
||||
workspaceStore,
|
||||
userWorkspaces,
|
||||
enterpriseLicense,
|
||||
usedTriggerKinds
|
||||
} from '$lib/stores'
|
||||
import {
|
||||
Route,
|
||||
Code,
|
||||
Eye,
|
||||
Pen,
|
||||
Plus,
|
||||
Shield,
|
||||
Trash,
|
||||
FileUp,
|
||||
ClipboardCopy,
|
||||
Pause
|
||||
} from 'lucide-svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import ListFilters from '$lib/components/home/ListFilters.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import { onMount } from 'svelte'
|
||||
import RouteEditor from '$lib/components/triggers/http/RouteEditor.svelte'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { getHttpRoute } from '$lib/components/triggers/http/utils'
|
||||
import RoutesGenerator from '$lib/components/triggers/http/RoutesGenerator.svelte'
|
||||
import OpenApiSpecGenerator from '$lib/components/triggers/http/OpenAPISpecGenerator.svelte'
|
||||
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
|
||||
|
||||
type TriggerW = HttpTrigger & { canWrite: boolean }
|
||||
|
||||
let triggers: TriggerW[] = $state([])
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
let loading = $state(true)
|
||||
let openAPISpecGenerator: OpenApiSpecGenerator | undefined = $state()
|
||||
let routesGenerator: RoutesGenerator | undefined = $state()
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
let globalHttpWorkspacedRoute = $state(false)
|
||||
|
||||
async function loadGlobalHttpWorkspacedRouteSetting() {
|
||||
try {
|
||||
const setting = await SettingService.getGlobal({ key: 'http_route_workspaced_route' })
|
||||
globalHttpWorkspacedRoute = (setting as boolean) ?? false
|
||||
} catch {
|
||||
globalHttpWorkspacedRoute = false
|
||||
}
|
||||
}
|
||||
|
||||
loadGlobalHttpWorkspacedRouteSetting()
|
||||
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
async function loadTriggers(): Promise<void> {
|
||||
triggers = (
|
||||
await HttpTriggerService.listHttpTriggers({
|
||||
workspace: $workspaceStore!,
|
||||
includeDraftOnly: true
|
||||
})
|
||||
).map((x) => {
|
||||
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
|
||||
})
|
||||
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'routes', $usedTriggerKinds)
|
||||
loading = false
|
||||
}
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore && $userStore) {
|
||||
loadTriggers()
|
||||
}
|
||||
})
|
||||
let routeEditor: RouteEditor | undefined = $state()
|
||||
|
||||
let hashHandled = false
|
||||
$effect(() => {
|
||||
if (!hashHandled && triggers.length > 0 && routeEditor) {
|
||||
let hash = $page.url.hash
|
||||
if (hash.length > 1) {
|
||||
let path = hash.slice(1)
|
||||
let trigger = triggers.find((t) => t.path === path)
|
||||
if (trigger) {
|
||||
hashHandled = true
|
||||
routeEditor?.openEdit(path, trigger.is_flow)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let filteredItems: (TriggerW & { marked?: any })[] | undefined = $state([])
|
||||
let items: typeof filteredItems | undefined = $state([])
|
||||
let preFilteredItems: typeof filteredItems | undefined = $state([])
|
||||
let filter = $state('')
|
||||
let ownerFilter: string | undefined = $state(undefined)
|
||||
let nbDisplayed = $state(15)
|
||||
|
||||
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
|
||||
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
|
||||
let selectedFilterKind = $state(
|
||||
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
|
||||
)
|
||||
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
|
||||
|
||||
run(() => {
|
||||
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
|
||||
})
|
||||
run(() => {
|
||||
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
|
||||
})
|
||||
|
||||
function filterItemsPathsBaseOnUserFilters(
|
||||
item: TriggerW,
|
||||
selectedFilterKind: 'trigger' | 'script_flow',
|
||||
filterUserFolders: boolean
|
||||
) {
|
||||
if ($workspaceStore == 'admins') return true
|
||||
if (filterUserFolders) {
|
||||
if (selectedFilterKind === 'trigger') {
|
||||
return (
|
||||
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
!item.script_path.startsWith('u/') ||
|
||||
item.script_path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
run(() => {
|
||||
preFilteredItems =
|
||||
ownerFilter != undefined
|
||||
? selectedFilterKind === 'trigger'
|
||||
? triggers?.filter(
|
||||
(x) =>
|
||||
x.path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter(
|
||||
(x) =>
|
||||
x.script_path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter((x) =>
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
})
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore) {
|
||||
ownerFilter = undefined
|
||||
}
|
||||
})
|
||||
|
||||
let owners = $derived(
|
||||
selectedFilterKind === 'trigger'
|
||||
? Array.from(
|
||||
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
: Array.from(
|
||||
new Set(
|
||||
filteredItems
|
||||
?.filter((x) => x.script_path)
|
||||
.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? []
|
||||
)
|
||||
).sort()
|
||||
)
|
||||
|
||||
run(() => {
|
||||
items = filter !== '' ? filteredItems : preFilteredItems
|
||||
})
|
||||
|
||||
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
|
||||
setQuery(
|
||||
TRIGGER_PATH_KIND_FILTER_SETTING,
|
||||
selectedFilterKind,
|
||||
window.location.hash || undefined
|
||||
).then(() => {
|
||||
setQuery(
|
||||
FILTER_USER_FOLDER_SETTING_NAME,
|
||||
String(filterUserFolders),
|
||||
window.location.hash || undefined
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function loadQueryFilters() {
|
||||
let url = new URL(window.location.href)
|
||||
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
|
||||
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
|
||||
if (queryFilterKind) {
|
||||
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
|
||||
}
|
||||
if (queryFilterUserFolders) {
|
||||
filterUserFolders = queryFilterUserFolders == 'true'
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<void> {
|
||||
try {
|
||||
// HTTP routes are always workspace-prefixed at runtime, so fork
|
||||
// and parent live at distinct URLs — no fork-conflict warning.
|
||||
await HttpTriggerService.setHttpTriggerMode({
|
||||
path,
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { mode }
|
||||
})
|
||||
} catch (err) {
|
||||
sendUserToast(
|
||||
`Cannot ` +
|
||||
(mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend') +
|
||||
` http trigger: ${err.body}`,
|
||||
true
|
||||
)
|
||||
} finally {
|
||||
loadTriggers()
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadQueryFilters()
|
||||
})
|
||||
|
||||
run(() => {
|
||||
updateQueryFilters(selectedFilterKind, filterUserFolders)
|
||||
})
|
||||
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
|
||||
</script>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<RouteEditor onUpdate={loadTriggers} bind:this={routeEditor} />
|
||||
|
||||
<RoutesGenerator closeFn={loadTriggers} bind:this={routesGenerator} />
|
||||
<OpenApiSpecGenerator bind:this={openAPISpecGenerator} />
|
||||
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={preFilteredItems}
|
||||
bind:filteredItems
|
||||
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
|
||||
/>
|
||||
|
||||
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers}
|
||||
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
|
||||
<p class="font-bold">Unauthorized</p>
|
||||
<p>Page not available for operators</p>
|
||||
</div>
|
||||
{:else}
|
||||
<CenteredPage>
|
||||
<PageHeader
|
||||
title="Custom HTTP routes"
|
||||
tooltip="Every script and flow already has a canonical HTTP API endpoint/webhook attached to it, this is to create additional parametrizable ones."
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/http_routing"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => {
|
||||
routesGenerator?.openDrawer()
|
||||
}}
|
||||
>
|
||||
From OpenAPI spec
|
||||
</Button>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => {
|
||||
openAPISpecGenerator?.openDrawer()
|
||||
}}
|
||||
>
|
||||
To OpenAPI spec
|
||||
</Button>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => routeEditor?.openNew(false)}
|
||||
>
|
||||
New route
|
||||
</Button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<div class="w-full pb-4 pt-6">
|
||||
<input type="text" placeholder="Search routes" bind:value={filter} class="search-item" />
|
||||
<div class="flex flex-row items-center gap-2 mt-2">
|
||||
<div class="text-xs font-semibold text-emphasis shrink-0"> Filter by path of </div>
|
||||
<ToggleButtonGroup bind:selected={selectedFilterKind}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="trigger" label="Route" icon={Route} {item} />
|
||||
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
|
||||
|
||||
<div class="flex flex-row items-center justify-end gap-4">
|
||||
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
|
||||
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
|
||||
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={filterUserFolders}
|
||||
options={{ right: `Only u/${$userStore.username} and f/*` }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if loading}
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[6], 0.4]} />
|
||||
{/each}
|
||||
{:else if !triggers?.length}
|
||||
<EmptyState
|
||||
icon={Route}
|
||||
title="No custom HTTP routes yet"
|
||||
description="Every script and flow already has a canonical HTTP endpoint attached to it. These are additional parametrizable ones."
|
||||
action={{
|
||||
label: 'Add a route',
|
||||
icon: Plus,
|
||||
onClick: () => routeEditor?.openNew(false),
|
||||
aiId: 'routes-empty-add',
|
||||
aiDescription: 'Add route'
|
||||
}}
|
||||
/>
|
||||
{:else if items?.length}
|
||||
<div class="border rounded-md divide-y">
|
||||
{#each items.slice(0, nbDisplayed) as { summary, workspace_id, workspaced_route, mode, path, edited_by, edited_at, script_path, route_path, is_flow, extra_perms, canWrite, marked, http_method, static_asset_config, retry, error_handler_path, error_handler_args, draft_only, is_draft } (path)}
|
||||
{@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_http', path) ?? is_draft}
|
||||
{@const effectiveMode = draft_only ? 'disabled' : mode}
|
||||
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
|
||||
|
||||
<div
|
||||
class="bg-surface-tertiary hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
|
||||
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
|
||||
>
|
||||
<div class="w-full flex gap-4 items-center">
|
||||
<RowIcon kind={is_flow ? 'flow' : 'script'} />
|
||||
|
||||
<a
|
||||
href="#{path}"
|
||||
onclick={() => routeEditor?.openEdit(path, is_flow)}
|
||||
class="min-w-0 grow hover:underline decoration-gray-400"
|
||||
>
|
||||
<div
|
||||
class="text-emphasis font-semibold flex-wrap text-left text-xs mb-1 truncate"
|
||||
>
|
||||
{#if marked}
|
||||
<span class="text-xs">
|
||||
{@html marked}
|
||||
</span>
|
||||
{:else if summary}
|
||||
{summary}
|
||||
{:else}
|
||||
{http_method.toUpperCase()}
|
||||
/{isCloudHosted() || workspaced_route || globalHttpWorkspacedRoute
|
||||
? workspace_id + '/' + route_path
|
||||
: route_path}
|
||||
{/if}{hasDraft ? '*' : ''}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left font-normal">
|
||||
{path}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left font-normal">
|
||||
{#if static_asset_config}
|
||||
file: {static_asset_config.s3}
|
||||
{:else}
|
||||
runnable: {script_path}
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="hidden lg:flex flex-row gap-1 items-center">
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
|
||||
<DraftBadge {draft_only} is_draft={hasDraft} />
|
||||
<TriggerModeToggle
|
||||
disabled={draft_only}
|
||||
title={draft_only
|
||||
? 'Draft only: deploy the trigger to enable it'
|
||||
: hasDraft
|
||||
? 'Enables/disables the deployed trigger; the draft is not affected'
|
||||
: undefined}
|
||||
onToggleMode={(mode) => onToggleMode(path, mode)}
|
||||
triggerMode={effectiveMode}
|
||||
includeModalConfig={{
|
||||
triggerPath: path,
|
||||
triggerKind: 'http',
|
||||
runnableConfig: {
|
||||
path: script_path,
|
||||
kind: is_flow ? 'flow' : 'script',
|
||||
retry,
|
||||
errorHandlerPath: error_handler_path,
|
||||
errorHandlerArgs: error_handler_args
|
||||
}
|
||||
}}
|
||||
{canWrite}
|
||||
hideToggleLabels
|
||||
hideDropdown
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center justify-end">
|
||||
<Button
|
||||
on:click={() =>
|
||||
copyToClipboard(
|
||||
getHttpRoute(
|
||||
'r',
|
||||
route_path,
|
||||
(workspaced_route ?? false) || globalHttpWorkspacedRoute,
|
||||
workspace_id
|
||||
)
|
||||
)}
|
||||
variant="subtle"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: ClipboardCopy }}
|
||||
>
|
||||
Copy URL
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => routeEditor?.openEdit(path, is_flow)}
|
||||
unifiedSize="md"
|
||||
startIcon={canWrite
|
||||
? { icon: Pen }
|
||||
: {
|
||||
icon: Eye
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
{canWrite ? 'Edit' : 'View'}
|
||||
</Button>
|
||||
<Dropdown
|
||||
size="md"
|
||||
items={[
|
||||
{
|
||||
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
|
||||
icon: Eye,
|
||||
action: () => {
|
||||
goto(href)
|
||||
}
|
||||
},
|
||||
...(canWrite && !draft_only && mode !== 'suspended'
|
||||
? [
|
||||
{
|
||||
displayName: 'Suspend job execution',
|
||||
icon: Pause,
|
||||
action: () => {
|
||||
onToggleMode(path, 'suspended')
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: canWrite ? 'Edit' : 'View',
|
||||
icon: canWrite ? Pen : Eye,
|
||||
action: () => {
|
||||
routeEditor?.openEdit(path, is_flow)
|
||||
}
|
||||
},
|
||||
...(isDeployable('trigger', path, deployUiSettings)
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer?.openDrawer(path, 'trigger', {
|
||||
triggers: {
|
||||
kind: 'routes'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Audit logs',
|
||||
icon: Eye,
|
||||
href: `${base}/audit_logs?resource=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
action: () => {
|
||||
shareModal?.openDrawer(path, 'http_trigger')
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
type: 'delete',
|
||||
icon: Trash,
|
||||
disabled: !canWrite,
|
||||
action: async () => {
|
||||
try {
|
||||
await HttpTriggerService.deleteHttpTrigger({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path
|
||||
})
|
||||
sendUserToast(`Successfully deleted HTTP route: ${path}`)
|
||||
loadTriggers()
|
||||
} catch (error) {
|
||||
sendUserToast(error.body || error.message, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-end items-baseline">
|
||||
<div
|
||||
class="flex flex-wrap text-2xs font-normal text-secondary gap-1 items-center justify-end truncate pr-2"
|
||||
>
|
||||
{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}
|
||||
<div class="truncate">{edited_by ? 'at ' : ''}{displayDate(edited_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<NoItemFound />
|
||||
{/if}
|
||||
</div>
|
||||
{#if items && items?.length > 15 && nbDisplayed < items.length}
|
||||
<span class="text-xs font-normal text-primary"
|
||||
>{nbDisplayed} items out of {items.length}
|
||||
<button class="ml-4 font-semibold text-emphasis" onclick={() => (nbDisplayed += 30)}
|
||||
>load 30 more</button
|
||||
></span
|
||||
>
|
||||
{/if}
|
||||
</CenteredPage>
|
||||
{/if}
|
||||
|
||||
<ShareModal
|
||||
bind:this={shareModal}
|
||||
on:change={() => {
|
||||
loadTriggers()
|
||||
}}
|
||||
/>
|
||||
<TriggerList triggerKind="http" />
|
||||
|
||||
@@ -1,683 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import {
|
||||
ScheduleService,
|
||||
type ScheduleWJobs,
|
||||
type WorkspaceDeployUISettings,
|
||||
WorkspaceService
|
||||
} from '$lib/gen'
|
||||
import { canWrite, displayDate, getLocalSetting, storeLocalSetting } from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { base } from '$app/paths'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import { Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { userStore, workspaceStore, userWorkspaces, enterpriseLicense } from '$lib/stores'
|
||||
import {
|
||||
Calendar,
|
||||
Circle,
|
||||
Copy,
|
||||
Eye,
|
||||
FileUp,
|
||||
List,
|
||||
Loader2,
|
||||
Pen,
|
||||
Play,
|
||||
Plus,
|
||||
SearchX,
|
||||
Shield,
|
||||
Trash
|
||||
} from 'lucide-svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import FilterSearchbar, {
|
||||
hasActiveFilters,
|
||||
useUrlSyncedFilterInstance
|
||||
} from '$lib/components/FilterSearchbar.svelte'
|
||||
import { buildSchedulesFilterSchema } from '$lib/components/schedules/schedulesFilter'
|
||||
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import JobPreview from '$lib/components/jobs/JobPreview.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { page } from '$app/stores'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
|
||||
import { runScheduleNow } from '$lib/components/triggers/scheduled/utils'
|
||||
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
|
||||
|
||||
type ScheduleW = ScheduleWJobs & { canWrite: boolean }
|
||||
|
||||
let schedules: ScheduleW[] = $state([])
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
let loading = $state(true)
|
||||
let loadingSchedulesWithJobStats = $state(true)
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
async function loadSchedules(): Promise<void> {
|
||||
const currentFilters = filters.val
|
||||
|
||||
// Build API parameters from filters.
|
||||
// `includeDraftOnly` surfaces per-user drafts at paths that have
|
||||
// no deployed schedule — appended server-side when no narrowing
|
||||
// filter is set, so the user sees AI-agent-created drafts on
|
||||
// the home page.
|
||||
const apiParams: any = {
|
||||
workspace: $workspaceStore!,
|
||||
includeDraftOnly: true
|
||||
}
|
||||
|
||||
if (currentFilters.schedule_path) {
|
||||
apiParams.schedulePath = currentFilters.schedule_path
|
||||
}
|
||||
if (currentFilters.path_start) {
|
||||
apiParams.pathStart = currentFilters.path_start
|
||||
}
|
||||
if (currentFilters.path) {
|
||||
apiParams.path = currentFilters.path
|
||||
}
|
||||
if (currentFilters.description) {
|
||||
apiParams.description = currentFilters.description
|
||||
}
|
||||
if (currentFilters.summary) {
|
||||
apiParams.summary = currentFilters.summary
|
||||
}
|
||||
if (currentFilters.args) {
|
||||
apiParams.args = currentFilters.args
|
||||
}
|
||||
if (currentFilters._default_) {
|
||||
apiParams.broadFilter = currentFilters._default_
|
||||
}
|
||||
if (currentFilters.label) {
|
||||
apiParams.label = currentFilters.label
|
||||
}
|
||||
|
||||
const result = (await ScheduleService.listSchedules(apiParams)).map((x) => {
|
||||
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
|
||||
})
|
||||
|
||||
// Extract unique values for autocomplete
|
||||
allPaths = Array.from(new Set(result.map((x) => x.path))).sort()
|
||||
allScriptPaths = Array.from(new Set(result.map((x) => x.script_path))).sort()
|
||||
allLabels = Array.from(
|
||||
new Set(result.flatMap((x) => [...(x.labels ?? []), ...(x.inherited_labels ?? [])]))
|
||||
).sort()
|
||||
|
||||
schedules = result
|
||||
loading = false
|
||||
// after the schedule core data has been loaded, load all the job stats
|
||||
// TODO: we could potentially not reload the job stats on every call to loadSchedules, but for now it's
|
||||
// simpler to always call it. Update if performance becomes an issue.
|
||||
loadSchedulesWithJobStats()
|
||||
}
|
||||
|
||||
// Reload schedules when filters change
|
||||
$effect(() => {
|
||||
filters.val
|
||||
if ($workspaceStore) {
|
||||
untrack(() => loadSchedules())
|
||||
}
|
||||
})
|
||||
|
||||
async function loadSchedulesWithJobStats(): Promise<void> {
|
||||
loadingSchedulesWithJobStats = true
|
||||
let schedulesWithJobsByPath = new Map<string, ScheduleW>()
|
||||
let schedulesWithJobsList = await ScheduleService.listSchedulesWithJobs({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
schedulesWithJobsList.map((x) => {
|
||||
schedulesWithJobsByPath[x.path] = x
|
||||
})
|
||||
for (let schedule of schedules) {
|
||||
if (schedulesWithJobsByPath[schedule.path]) {
|
||||
schedule.jobs = schedulesWithJobsByPath[schedule.path].jobs
|
||||
}
|
||||
}
|
||||
loadingSchedulesWithJobStats = false
|
||||
}
|
||||
|
||||
// Per-path counter bumped when a schedule toggle is cancelled or errors,
|
||||
// to force-remount that row's <Toggle>. Toggle uses `bind:checked` on
|
||||
// its native input; once the user clicks, the local checkbox state
|
||||
// diverges from the parent's prop expression, and Svelte 5 prop
|
||||
// reactivity won't push a same-valued prop back down. Re-mounting
|
||||
// re-initializes from the prop. List-page rows don't optimistically
|
||||
// flip `enabled`, so they need this nudge — but only the affected row,
|
||||
// not all rows on the page.
|
||||
let toggleResetVersions = $state<Record<string, number>>({})
|
||||
function bumpToggleReset(path: string) {
|
||||
toggleResetVersions[path] = (toggleResetVersions[path] ?? 0) + 1
|
||||
}
|
||||
|
||||
async function setScheduleEnabled(path: string, enabled: boolean): Promise<void> {
|
||||
try {
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
ScheduleService.setScheduleEnabled({
|
||||
path,
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { enabled, force }
|
||||
}),
|
||||
'schedule'
|
||||
)
|
||||
if (ok) {
|
||||
loadSchedules()
|
||||
} else {
|
||||
// Cancelled — nothing changed on the server, skip the reload
|
||||
// (which would re-fetch job stats and flash the loading flag)
|
||||
// and just nudge the toggle back to the prop value.
|
||||
bumpToggleReset(path)
|
||||
}
|
||||
} catch (err) {
|
||||
sendUserToast(`Cannot ` + (enabled ? 'enable' : 'disable') + ` schedule: ${err.body}`, true)
|
||||
bumpToggleReset(path)
|
||||
loadSchedules()
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($workspaceStore && $userStore) {
|
||||
untrack(() => {
|
||||
loadSchedules()
|
||||
})
|
||||
}
|
||||
})
|
||||
let scheduleEditor: ScheduleEditor | undefined = $state()
|
||||
|
||||
// Deep link: #<path> opens that schedule's edit drawer. Tracks the last
|
||||
// handled hash (not a one-shot flag) so a hash change on the already-mounted
|
||||
// page (e.g. the AI session preview re-pointing its tab) opens the drawer
|
||||
// too. Row links pre-set handledHash: their onclick already opens the drawer.
|
||||
let handledHash = ''
|
||||
$effect(() => {
|
||||
const hash = $page.url.hash
|
||||
if (hash.length <= 1) {
|
||||
// Navigating away from a drawer target must clear the tracker, or
|
||||
// re-targeting the same schedule later would be skipped as already handled.
|
||||
handledHash = ''
|
||||
return
|
||||
}
|
||||
if (hash === handledHash || schedules.length === 0 || !scheduleEditor) return
|
||||
const path = hash.slice(1)
|
||||
const schedule = schedules.find((s) => s.path === path)
|
||||
if (schedule) {
|
||||
handledHash = hash
|
||||
scheduleEditor.openEdit(path, schedule.is_flow)
|
||||
}
|
||||
})
|
||||
|
||||
// Collect unique values for filter autocomplete
|
||||
let allPaths: string[] = $state([])
|
||||
let allScriptPaths: string[] = $state([])
|
||||
let allLabels: string[] = $state([])
|
||||
|
||||
// FilterSearchbar setup
|
||||
let userFoldersFilterType = $derived(
|
||||
$userStore?.is_super_admin && $userStore.username.includes('@')
|
||||
? 'only f/*'
|
||||
: $userStore?.is_admin || $userStore?.is_super_admin
|
||||
? 'u/username and f/*'
|
||||
: undefined
|
||||
)
|
||||
let schedulesFilterSchema = $derived(
|
||||
buildSchedulesFilterSchema({
|
||||
paths: allPaths,
|
||||
scriptPaths: allScriptPaths,
|
||||
labels: allLabels,
|
||||
showUserFoldersFilter: userFoldersFilterType !== undefined,
|
||||
userFoldersLabel:
|
||||
userFoldersFilterType === 'only f/*' ? 'Only f/*' : `Only u/${$userStore?.username} and f/*`
|
||||
})
|
||||
)
|
||||
let filters = useUrlSyncedFilterInstance(untrack(() => schedulesFilterSchema))
|
||||
|
||||
let activeFilters = $derived(hasActiveFilters(filters.val))
|
||||
let allFolders = $derived(
|
||||
Array.from(
|
||||
new Set(
|
||||
(schedules ?? [])
|
||||
.map((x) => x.path.split('/').slice(0, 2).join('/'))
|
||||
.filter((x) => x.startsWith('f/'))
|
||||
)
|
||||
)
|
||||
.sort()
|
||||
.map((f) => f.replace(/^f\//, ''))
|
||||
)
|
||||
let presets = $derived([
|
||||
...allFolders.map((f) => ({ name: `f/${f}`, value: `path_start:\\ f/${f}/` })),
|
||||
...allLabels.map((l) => ({ name: l, value: `label:\\ ${l}` })),
|
||||
...(schedulesFilterSchema.user_folders_only
|
||||
? [
|
||||
{
|
||||
name: schedulesFilterSchema.user_folders_only.label ?? '?',
|
||||
value: 'user_folders_only:\\ true'
|
||||
}
|
||||
]
|
||||
: [])
|
||||
])
|
||||
|
||||
let nbDisplayed = $state(15)
|
||||
let filterEnabledDisabled: 'all' | 'enabled' | 'disabled' = $state('all')
|
||||
|
||||
const SCHEDULE_PATH_KIND_FILTER_SETTING = 'schedulePathKindFilter'
|
||||
let selectedFilterKind = $state(
|
||||
(getLocalSetting(SCHEDULE_PATH_KIND_FILTER_SETTING) as 'schedule' | 'script_flow') ?? 'schedule'
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
storeLocalSetting(SCHEDULE_PATH_KIND_FILTER_SETTING, selectedFilterKind)
|
||||
})
|
||||
|
||||
function filterItemsPathsBaseOnUserFilters(
|
||||
item: ScheduleW,
|
||||
selectedFilterKind: 'schedule' | 'script_flow',
|
||||
userFoldersOnly: boolean
|
||||
) {
|
||||
if (userFoldersOnly) {
|
||||
if (selectedFilterKind === 'schedule') {
|
||||
return (
|
||||
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
!item.script_path.startsWith('u/') ||
|
||||
item.script_path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function filterItemsBasedOnEnabledDisabled(
|
||||
item: ScheduleW,
|
||||
filterEnabledDisabled: 'all' | 'enabled' | 'disabled'
|
||||
) {
|
||||
// Draft-only rows have nothing deployed, so they read as disabled here
|
||||
// too — matching the off, disabled toggle the row renders.
|
||||
const enabled = !item.draft_only && item.enabled
|
||||
if (filterEnabledDisabled === 'all') return true
|
||||
if (filterEnabledDisabled === 'enabled') return enabled
|
||||
if (filterEnabledDisabled === 'disabled') return !enabled
|
||||
}
|
||||
|
||||
// Filter schedules client-side for enabled/disabled and user folders
|
||||
let filteredItems = $derived.by(() => {
|
||||
return schedules?.filter(
|
||||
(x) =>
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, !!filters.val.user_folders_only) &&
|
||||
filterItemsBasedOnEnabledDisabled(x, filterEnabledDisabled)
|
||||
)
|
||||
})
|
||||
|
||||
let items = $derived(filteredItems)
|
||||
import SchedulesList from '$lib/components/schedules/SchedulesList.svelte'
|
||||
</script>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<ScheduleEditor onUpdate={loadSchedules} bind:this={scheduleEditor} />
|
||||
|
||||
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.schedules}
|
||||
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
|
||||
<p class="font-bold">Unauthorized</p>
|
||||
<p>Page not available for operators</p>
|
||||
</div>
|
||||
{:else}
|
||||
<CenteredPage>
|
||||
<PageHeader
|
||||
title="Schedules"
|
||||
tooltip="Trigger Scripts and Flows according to a cron schedule"
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/scheduling"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => scheduleEditor?.openNew(false)}
|
||||
aiId="schedules-add-schedule"
|
||||
aiDescription="Add schedule"
|
||||
>
|
||||
New schedule
|
||||
</Button>
|
||||
</PageHeader>
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<div class="flex flex-row items-center justify-end gap-4 pb-4">
|
||||
<ToggleButtonGroup bind:selected={filterEnabledDisabled} class="w-fit">
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="all" label="All" {item} />
|
||||
<ToggleButton value="enabled" label="Enabled" {item} />
|
||||
<ToggleButton value="disabled" label="Disabled" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
<FilterSearchbar
|
||||
schema={schedulesFilterSchema}
|
||||
bind:value={filters.val}
|
||||
class="grow max-w-[26rem]"
|
||||
{presets}
|
||||
/>
|
||||
</div>
|
||||
{#if loading}
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[6], 0.4]} />
|
||||
{/each}
|
||||
{:else if !schedules?.length}
|
||||
{#if activeFilters}
|
||||
<EmptyState
|
||||
icon={SearchX}
|
||||
title="No schedules found"
|
||||
description="No schedule matches the current filters. Try clearing or widening them."
|
||||
/>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={Calendar}
|
||||
title="No schedules yet"
|
||||
description="Schedules run a script or flow automatically on a cron expression."
|
||||
action={{
|
||||
label: 'Add a schedule',
|
||||
icon: Plus,
|
||||
onClick: () => scheduleEditor?.openNew(false),
|
||||
aiId: 'schedules-empty-add',
|
||||
aiDescription: 'Add schedule'
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{:else if items?.length}
|
||||
<div class="border rounded-md divide-y">
|
||||
{#each items.slice(0, nbDisplayed) as { path, error, summary, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, jobs, paused_until, labels, inherited_labels, draft_only, is_draft } (path)}
|
||||
{@const hasDraft =
|
||||
getLocalDraftHint($workspaceStore, 'trigger_schedule', path) ?? is_draft}
|
||||
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
|
||||
{@const avg_s = jobs
|
||||
? jobs.reduce((acc, x) => acc + x.duration_ms, 0) / jobs.length
|
||||
: undefined}
|
||||
|
||||
<div
|
||||
class="bg-surface-tertiary hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
|
||||
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
|
||||
>
|
||||
<div class="w-full flex gap-4 items-center">
|
||||
<RowIcon kind={is_flow ? 'flow' : 'script'} />
|
||||
|
||||
<a
|
||||
href="#{path}"
|
||||
onclick={() => {
|
||||
handledHash = `#${path}`
|
||||
scheduleEditor?.openEdit(path, is_flow)
|
||||
}}
|
||||
class="min-w-0 grow hover:underline decoration-gray-400"
|
||||
>
|
||||
<div
|
||||
class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate"
|
||||
>
|
||||
{summary || script_path}{hasDraft ? '*' : ''}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left">
|
||||
schedule: {path}
|
||||
</div>
|
||||
</a>
|
||||
{#if labels?.length}
|
||||
{#each labels as label}
|
||||
<Badge
|
||||
color="blue"
|
||||
small
|
||||
class="px-1"
|
||||
title="Label: {label}"
|
||||
clickable
|
||||
onclick={() => {
|
||||
const arr = (filters.val.label ?? '').split(',').filter(Boolean)
|
||||
const idx = arr.indexOf(label)
|
||||
if (idx >= 0) arr.splice(idx, 1)
|
||||
else arr.push(label)
|
||||
const newFilters = { ...filters.val }
|
||||
if (arr.length) newFilters.label = arr.join(',')
|
||||
else delete newFilters.label
|
||||
filters.val = newFilters
|
||||
}}>{label}</Badge
|
||||
>
|
||||
{/each}
|
||||
{/if}
|
||||
<InheritedLabels labels={inherited_labels} />
|
||||
|
||||
{#if paused_until && new Date(paused_until) > new Date()}
|
||||
<div class="pb-1">
|
||||
<Badge color="yellow"
|
||||
>Paused until {new Date(paused_until).toLocaleString()}</Badge
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="gap-2 items-center hidden md:flex">
|
||||
<Badge large color="blue">{schedule}</Badge>
|
||||
<Badge small color="gray">{timezone}</Badge>
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:flex flex-row gap-1 items-center">
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
</div>
|
||||
|
||||
<div class="w-10">
|
||||
{#if error}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle
|
||||
class="text-red-600 animate-ping absolute inline-flex fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
The schedule disabled itself because there was an error scheduling the
|
||||
next job: {error}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
|
||||
<DraftBadge {draft_only} is_draft={hasDraft} />
|
||||
{#key toggleResetVersions[path] ?? 0}
|
||||
<Toggle
|
||||
disabled={draft_only}
|
||||
options={{
|
||||
title: draft_only
|
||||
? 'Draft only: deploy the schedule to enable it'
|
||||
: hasDraft
|
||||
? 'Enables/disables the deployed schedule; the draft is not affected'
|
||||
: undefined
|
||||
}}
|
||||
checked={!draft_only && enabled}
|
||||
on:change={(e) => {
|
||||
if (canWrite) {
|
||||
setScheduleEnabled(path, e.detail)
|
||||
} else {
|
||||
sendUserToast('not enough permission', true)
|
||||
// Permission denied — bump the row's reset
|
||||
// counter so the Toggle remounts back to the
|
||||
// prop value. Without this, the local
|
||||
// `bind:checked` flip from the user's click
|
||||
// stays stuck on.
|
||||
bumpToggleReset(path)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
<div class="flex gap-2 items-center justify-end">
|
||||
<Button
|
||||
href={`${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true`}
|
||||
disabled={draft_only}
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: List }}
|
||||
variant="subtle"
|
||||
>
|
||||
Runs
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => scheduleEditor?.openEdit(path, is_flow)}
|
||||
size="xs"
|
||||
startIcon={{ icon: canWrite ? Pen : Eye }}
|
||||
variant="subtle"
|
||||
>
|
||||
{canWrite ? 'Edit' : 'View'}
|
||||
</Button>
|
||||
<Dropdown
|
||||
size="md"
|
||||
items={[
|
||||
{
|
||||
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
|
||||
icon: Eye,
|
||||
action: () => {
|
||||
goto(href)
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: `Duplicate schedule`,
|
||||
icon: Copy,
|
||||
action: () => {
|
||||
scheduleEditor?.openNew(is_flow, script_path, path)
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
type: 'delete',
|
||||
icon: Trash,
|
||||
disabled: !canWrite,
|
||||
action: async () => {
|
||||
await ScheduleService.deleteSchedule({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path
|
||||
})
|
||||
loadSchedules()
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: canWrite ? 'Edit' : 'View',
|
||||
icon: canWrite ? Pen : Eye,
|
||||
action: () => {
|
||||
scheduleEditor?.openEdit(path, is_flow)
|
||||
}
|
||||
},
|
||||
...(isDeployable('trigger', path, deployUiSettings)
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer?.openDrawer(path, 'trigger', {
|
||||
triggers: {
|
||||
kind: 'schedules'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'View runs',
|
||||
icon: List,
|
||||
href: `${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true`
|
||||
},
|
||||
{
|
||||
displayName: 'Audit logs',
|
||||
icon: Eye,
|
||||
href: `${base}/audit_logs?resource=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Run now',
|
||||
icon: Play,
|
||||
action: () => {
|
||||
runScheduleNow(script_path, path, is_flow, $workspaceStore!)
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
action: () => {
|
||||
shareModal?.openDrawer(path, 'schedule')
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-between items-baseline">
|
||||
{#if loadingSchedulesWithJobStats}
|
||||
<div class="flex gap-1 ml-0.5 text-xs text-secondary items-center">
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
<span>Job stats loading...</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex gap-1.5 ml-0.5 items-baseline flex-row-reverse">
|
||||
{#if avg_s}
|
||||
<div class="pl-2 text-secondary text-xs"
|
||||
>Avg: {(avg_s / 1000).toFixed(2)}s</div
|
||||
>
|
||||
{/if}
|
||||
{#each jobs ?? [] as job}
|
||||
{@const h = (avg_s ? job.duration_ms / avg_s : 1) * 7 + 3}
|
||||
<a href="{base}/run/{job.id}?workspace={$workspaceStore}">
|
||||
<JobPreview id={job.id} class="p-4">
|
||||
<div>
|
||||
<div
|
||||
class="{job.success ? 'bg-green-300' : 'bg-red-300'} mx-auto w-1.5"
|
||||
style="height: {h}px"
|
||||
></div>
|
||||
<!-- <div class="text-[0.6em] mt-0.5 text-center text-primary"
|
||||
>{(job.duration_ms / 1000).toFixed(2)}s</div
|
||||
> -->
|
||||
</div>
|
||||
</JobPreview>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="flex flex-wrap text-xs text-secondary gap-1 items-center justify-end truncate pr-2"
|
||||
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
|
||||
class="truncate">{edited_by ? 'the ' : ''}{displayDate(edited_at)}</div
|
||||
></div
|
||||
></div
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<NoItemFound />
|
||||
{/if}
|
||||
</div>
|
||||
{#if items && items?.length > 15 && nbDisplayed < items.length}
|
||||
<div class="flex items-center gap-4 text-xs font-semibold text-emphasis">
|
||||
<span>{nbDisplayed} items out of {items.length}</span>
|
||||
<Button size="xs" variant="subtle" on:click={() => (nbDisplayed += 30)}>
|
||||
Load 30 more
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</CenteredPage>
|
||||
{/if}
|
||||
|
||||
<ShareModal
|
||||
bind:this={shareModal}
|
||||
on:change={() => {
|
||||
loadSchedules()
|
||||
}}
|
||||
/>
|
||||
<SchedulesList />
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
type PreviewTarget
|
||||
} from '$lib/components/sessions/previewRouter'
|
||||
import { toolReloadEffect, tabsToReload } from '$lib/components/sessions/previewReload'
|
||||
import { pageItemForListPath } from '$lib/components/sessions/previewPaths'
|
||||
import { isPageItemListPath, stripBase } from '$lib/components/sessions/previewPaths'
|
||||
import {
|
||||
leafKeyFor,
|
||||
loadKind,
|
||||
@@ -834,12 +834,11 @@
|
||||
owner?.navigate({ type: 'item', item })
|
||||
return
|
||||
}
|
||||
// A row opened on a list page inside a preview tab: its editor opens as a tab of its
|
||||
// own, leaving the list where it is.
|
||||
if (d.type === 'wm.session.openPageItem') {
|
||||
if (typeof d.pagePath !== 'string' || typeof d.path !== 'string') return
|
||||
const ref = pageItemForListPath(d.pagePath, d.path)
|
||||
if (ref) owner?.open({ type: 'pageitem', ref })
|
||||
// A preview frame navigating to a list page: re-point the active tab to the list
|
||||
// mounted in process, whose rows open as tabs of their own.
|
||||
if (d.type === 'wm.session.openList') {
|
||||
if (typeof d.href !== 'string' || !isPageItemListPath(stripBase(d.href))) return
|
||||
owner?.navigate({ type: 'page', href: d.href, label: previewLocationLabel(d.href) })
|
||||
return
|
||||
}
|
||||
// A job clicked inside a preview tab: open the run detail in a NEW tab so the
|
||||
|
||||
@@ -1,567 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { run } from 'svelte/legacy'
|
||||
|
||||
import {
|
||||
SqsTriggerService,
|
||||
WorkspaceService,
|
||||
type SqsTrigger,
|
||||
type TriggerMode,
|
||||
type WorkspaceDeployUISettings
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
canWrite,
|
||||
displayDate,
|
||||
getLocalSetting,
|
||||
sendUserToast,
|
||||
storeLocalSetting,
|
||||
removeTriggerKindIfUnused
|
||||
} from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { base } from '$app/paths'
|
||||
import { page } from '$app/stores'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { enterpriseLicense, usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
|
||||
import { Code, Eye, Pen, Plus, Shield, Trash, Circle, FileUp, Pause } from 'lucide-svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import ListFilters from '$lib/components/home/ListFilters.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import SqsTriggerEditor from '$lib/components/triggers/sqs/SqsTriggerEditor.svelte'
|
||||
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import AwsIcon from '$lib/components/icons/AwsIcon.svelte'
|
||||
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
|
||||
|
||||
type TriggerD = SqsTrigger & { canWrite: boolean }
|
||||
|
||||
let triggers: TriggerD[] = $state([])
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
let loading = $state(true)
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
async function loadTriggers(): Promise<void> {
|
||||
triggers = (
|
||||
await SqsTriggerService.listSqsTriggers({
|
||||
workspace: $workspaceStore!,
|
||||
includeDraftOnly: true
|
||||
})
|
||||
).map((x) => {
|
||||
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
|
||||
})
|
||||
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'sqs', $usedTriggerKinds)
|
||||
loading = false
|
||||
}
|
||||
|
||||
let interval = setInterval(async () => {
|
||||
try {
|
||||
const newTriggers = await SqsTriggerService.listSqsTriggers({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
for (let i = 0; i < triggers.length; i++) {
|
||||
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
|
||||
if (newTrigger) {
|
||||
triggers[i] = {
|
||||
...triggers[i],
|
||||
error: newTrigger.error,
|
||||
last_server_ping: newTrigger.last_server_ping,
|
||||
mode: newTrigger.mode,
|
||||
server_id: newTrigger.server_id
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(interval)
|
||||
})
|
||||
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
|
||||
let committed = false
|
||||
try {
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
SqsTriggerService.setSqsTriggerMode({
|
||||
path,
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { mode, force }
|
||||
}),
|
||||
'SQS trigger'
|
||||
)
|
||||
committed = ok
|
||||
if (ok) loadTriggers()
|
||||
} catch (err) {
|
||||
sendUserToast(
|
||||
`Cannot ` +
|
||||
(mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend') +
|
||||
` sqs trigger: ${err.body}`,
|
||||
true
|
||||
)
|
||||
loadTriggers()
|
||||
}
|
||||
return committed
|
||||
}
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore && $userStore) {
|
||||
loadTriggers()
|
||||
}
|
||||
})
|
||||
let sqsTriggerEditor: SqsTriggerEditor | undefined = $state()
|
||||
|
||||
let hashHandled = false
|
||||
$effect(() => {
|
||||
if (!hashHandled && triggers.length > 0 && sqsTriggerEditor) {
|
||||
let hash = $page.url.hash
|
||||
if (hash.length > 1) {
|
||||
let path = hash.slice(1)
|
||||
let trigger = triggers.find((t) => t.path === path)
|
||||
if (trigger) {
|
||||
hashHandled = true
|
||||
sqsTriggerEditor?.openEdit(path, trigger.is_flow)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let filteredItems: (TriggerD & { marked?: any })[] | undefined = $state([])
|
||||
let items: typeof filteredItems | undefined = $state([])
|
||||
let preFilteredItems: typeof filteredItems | undefined = $state([])
|
||||
let filter = $state('')
|
||||
let ownerFilter: string | undefined = $state(undefined)
|
||||
let nbDisplayed = $state(15)
|
||||
|
||||
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
|
||||
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
|
||||
let selectedFilterKind = $state(
|
||||
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
|
||||
)
|
||||
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
|
||||
|
||||
run(() => {
|
||||
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
|
||||
})
|
||||
run(() => {
|
||||
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
|
||||
})
|
||||
|
||||
function filterItemsPathsBaseOnUserFilters(
|
||||
item: TriggerD,
|
||||
selectedFilterKind: 'trigger' | 'script_flow',
|
||||
filterUserFolders: boolean
|
||||
) {
|
||||
if ($workspaceStore == 'admins') return true
|
||||
if (filterUserFolders) {
|
||||
if (selectedFilterKind === 'trigger') {
|
||||
return (
|
||||
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
!item.script_path.startsWith('u/') ||
|
||||
item.script_path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
run(() => {
|
||||
preFilteredItems =
|
||||
ownerFilter != undefined
|
||||
? selectedFilterKind === 'trigger'
|
||||
? triggers?.filter(
|
||||
(x) =>
|
||||
x.path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter(
|
||||
(x) =>
|
||||
x.script_path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter((x) =>
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
})
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore) {
|
||||
ownerFilter = undefined
|
||||
}
|
||||
})
|
||||
|
||||
let owners = $derived(
|
||||
selectedFilterKind === 'trigger'
|
||||
? Array.from(
|
||||
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
: Array.from(
|
||||
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
)
|
||||
|
||||
run(() => {
|
||||
items = filter !== '' ? filteredItems : preFilteredItems
|
||||
})
|
||||
|
||||
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
|
||||
setQuery(
|
||||
TRIGGER_PATH_KIND_FILTER_SETTING,
|
||||
selectedFilterKind,
|
||||
window.location.hash || undefined
|
||||
).then(() => {
|
||||
setQuery(
|
||||
FILTER_USER_FOLDER_SETTING_NAME,
|
||||
String(filterUserFolders),
|
||||
window.location.hash || undefined
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function loadQueryFilters() {
|
||||
let url = new URL(window.location.href)
|
||||
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
|
||||
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
|
||||
if (queryFilterKind) {
|
||||
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
|
||||
}
|
||||
if (queryFilterUserFolders) {
|
||||
filterUserFolders = queryFilterUserFolders == 'true'
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadQueryFilters()
|
||||
})
|
||||
|
||||
run(() => {
|
||||
updateQueryFilters(selectedFilterKind, filterUserFolders)
|
||||
})
|
||||
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
|
||||
</script>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<SqsTriggerEditor onUpdate={loadTriggers} bind:this={sqsTriggerEditor} />
|
||||
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={preFilteredItems}
|
||||
bind:filteredItems
|
||||
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
|
||||
/>
|
||||
|
||||
<CenteredPage>
|
||||
<PageHeader title="SQS triggers" tooltip="SQS trigger">
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => sqsTriggerEditor?.openNew(false)}
|
||||
>
|
||||
New SQS trigger
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{#if isCloudHosted()}
|
||||
<Alert title="Not compatible with multi-tenant cloud" type="warning">
|
||||
SQS triggers are disabled in the multi-tenant cloud.
|
||||
</Alert>
|
||||
<div class="py-4"></div>
|
||||
{/if}
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<div class="w-full pb-4 pt-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search SQS triggers"
|
||||
bind:value={filter}
|
||||
class="search-item"
|
||||
/>
|
||||
<div class="flex flex-row items-center gap-2 mt-2">
|
||||
<div class="text-sm shrink-0"> Filter by path of </div>
|
||||
<ToggleButtonGroup bind:selected={selectedFilterKind}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="trigger" label="SQS trigger" icon={AwsIcon} {item} />
|
||||
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
|
||||
|
||||
<div class="flex flex-row items-center justify-end gap-4">
|
||||
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
|
||||
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
|
||||
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={filterUserFolders}
|
||||
options={{ right: `Only u/${$userStore.username} and f/*` }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if loading}
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[6], 0.4]} />
|
||||
{/each}
|
||||
{:else if !triggers?.length}
|
||||
<EmptyState
|
||||
icon={AwsIcon}
|
||||
title="No SQS triggers yet"
|
||||
description="Windmill can consume messages from an SQS queue and trigger scripts or flows on each one."
|
||||
action={{
|
||||
label: 'Add an SQS trigger',
|
||||
icon: Plus,
|
||||
onClick: () => sqsTriggerEditor?.openNew(false),
|
||||
aiId: 'sqs-triggers-empty-add',
|
||||
aiDescription: 'Add SQS trigger'
|
||||
}}
|
||||
/>
|
||||
{:else if items?.length}
|
||||
<div class="border rounded-md divide-y">
|
||||
{#each items.slice(0, nbDisplayed) as { path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
|
||||
{@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_sqs', path) ?? is_draft}
|
||||
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
|
||||
{@const ping = new Date()}
|
||||
{@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000}
|
||||
{@const effectiveMode = draft_only ? 'disabled' : mode}
|
||||
{@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'}
|
||||
|
||||
<div
|
||||
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
|
||||
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
|
||||
>
|
||||
<div class="w-full flex gap-5 items-center">
|
||||
<RowIcon kind={is_flow ? 'flow' : 'script'} />
|
||||
|
||||
<a
|
||||
href="#{path}"
|
||||
onclick={() => sqsTriggerEditor?.openEdit(path, is_flow)}
|
||||
class="min-w-0 grow hover:underline decoration-gray-400"
|
||||
>
|
||||
<div class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate">
|
||||
{path}{hasDraft ? '*' : ''}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left font-light"></div>
|
||||
<div class="text-secondary text-xs truncate text-left font-light">
|
||||
runnable: {script_path}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="hidden lg:flex flex-row gap-1 items-center">
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
{#if labels?.length}
|
||||
{#each labels as label}
|
||||
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="w-10">
|
||||
{#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle
|
||||
class="text-red-600 animate-ping absolute inline-flex fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
{#if enabled}
|
||||
{#if !server_id}
|
||||
SQS trigger is starting...
|
||||
{:else}
|
||||
Could not connect to SQS{error ? ': ' + error : ''}
|
||||
{/if}
|
||||
{:else}
|
||||
Disabled because of an error: {error}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else if enabled}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle class="text-green-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div> Connected to SQS{!server_id ? ' (shutting down...)' : ''}</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
|
||||
<DraftBadge {draft_only} is_draft={hasDraft} />
|
||||
<TriggerModeToggle
|
||||
disabled={draft_only}
|
||||
title={draft_only
|
||||
? 'Draft only: deploy the trigger to enable it'
|
||||
: hasDraft
|
||||
? 'Enables/disables the deployed trigger; the draft is not affected'
|
||||
: undefined}
|
||||
onToggleMode={(newMode) => onToggleMode(path, newMode)}
|
||||
triggerMode={effectiveMode}
|
||||
includeModalConfig={{
|
||||
triggerPath: path,
|
||||
triggerKind: 'sqs',
|
||||
runnableConfig: {
|
||||
path: script_path,
|
||||
kind: is_flow ? 'flow' : 'script',
|
||||
retry,
|
||||
errorHandlerPath: error_handler_path,
|
||||
errorHandlerArgs: error_handler_args
|
||||
}
|
||||
}}
|
||||
{canWrite}
|
||||
hideToggleLabels
|
||||
hideDropdown
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center justify-end">
|
||||
<Button
|
||||
on:click={() => sqsTriggerEditor?.openEdit(path, is_flow)}
|
||||
size="xs"
|
||||
startIcon={canWrite
|
||||
? { icon: Pen }
|
||||
: {
|
||||
icon: Eye
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
{canWrite ? 'Edit' : 'View'}
|
||||
</Button>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
|
||||
icon: Eye,
|
||||
action: () => {
|
||||
goto(href)
|
||||
}
|
||||
},
|
||||
...(canWrite && !draft_only && mode !== 'suspended'
|
||||
? [
|
||||
{
|
||||
displayName: 'Suspend job execution',
|
||||
icon: Pause,
|
||||
action: () => {
|
||||
onToggleMode(path, 'suspended')
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: canWrite ? 'Edit' : 'View',
|
||||
icon: canWrite ? Pen : Eye,
|
||||
action: () => {
|
||||
sqsTriggerEditor?.openEdit(path, is_flow)
|
||||
}
|
||||
},
|
||||
...(isDeployable('trigger', path, deployUiSettings)
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer?.openDrawer(path, 'trigger', {
|
||||
triggers: {
|
||||
kind: 'sqs'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Audit logs',
|
||||
icon: Eye,
|
||||
href: `${base}/audit_logs?resource=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
action: () => {
|
||||
shareModal?.openDrawer(path, 'sqs_trigger')
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
type: 'delete',
|
||||
icon: Trash,
|
||||
disabled: !canWrite,
|
||||
action: async () => {
|
||||
try {
|
||||
await SqsTriggerService.deleteSqsTrigger({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path
|
||||
})
|
||||
loadTriggers()
|
||||
} catch (error) {
|
||||
sendUserToast(error.body, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-between items-baseline">
|
||||
<div
|
||||
class="flex flex-wrap text-[0.7em] text-primary gap-1 items-center justify-end truncate pr-2"
|
||||
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
|
||||
class="truncate">{edited_by ? 'the ' : ''}{displayDate(edited_at)}</div
|
||||
></div
|
||||
></div
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<NoItemFound />
|
||||
{/if}
|
||||
</div>
|
||||
{#if items && items?.length > 15 && nbDisplayed < items.length}
|
||||
<span class="text-xs"
|
||||
>{nbDisplayed} items out of {items.length}
|
||||
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
|
||||
>
|
||||
{/if}
|
||||
</CenteredPage>
|
||||
|
||||
<ShareModal
|
||||
bind:this={shareModal}
|
||||
on:change={() => {
|
||||
loadTriggers()
|
||||
}}
|
||||
/>
|
||||
<TriggerList triggerKind="sqs" />
|
||||
|
||||
@@ -1,722 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
Tab,
|
||||
TabFade,
|
||||
Tabs
|
||||
} from '$lib/components/common'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { OauthService, VariableService, WorkspaceService } from '$lib/gen'
|
||||
import ContextualVariableEditor from '$lib/components/ContextualVariableEditor.svelte'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import FilterSearchbar, {
|
||||
hasActiveFilters,
|
||||
useUrlSyncedFilterInstance
|
||||
} from '$lib/components/FilterSearchbar.svelte'
|
||||
import { buildVariablesFilterSchema } from '$lib/components/variables/variablesFilter'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import Cell from '$lib/components/table/Cell.svelte'
|
||||
import DataTable from '$lib/components/table/DataTable.svelte'
|
||||
import Head from '$lib/components/table/Head.svelte'
|
||||
import Row from '$lib/components/table/Row.svelte'
|
||||
import TableSimple from '$lib/components/TableSimple.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import VariableEditor from '$lib/components/VariableEditor.svelte'
|
||||
import type { ContextualVariable, ListableVariable, WorkspaceDeployUISettings } from '$lib/gen'
|
||||
import { enterpriseLicense, userStore, workspaceStore, userWorkspaces } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { canWrite, isOwner, truncate } from '$lib/utils'
|
||||
import { isDeployable, ALL_DEPLOYABLE } from '$lib/utils_deployable'
|
||||
import {
|
||||
Plus,
|
||||
FileUp,
|
||||
Link,
|
||||
Pen,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Trash,
|
||||
Building,
|
||||
DollarSign,
|
||||
EyeOff,
|
||||
Circle,
|
||||
SearchX
|
||||
} from 'lucide-svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { page } from '$app/stores'
|
||||
|
||||
type ListableVariableW = ListableVariable & { canWrite: boolean }
|
||||
|
||||
let variables = $state(undefined) as ListableVariableW[] | undefined
|
||||
let showCreateButtons = $state(false)
|
||||
|
||||
// Collect unique values for filter autocomplete
|
||||
let allPaths: string[] = $state([])
|
||||
let allOwners: string[] = $state([])
|
||||
let allLabels: string[] = $state([])
|
||||
|
||||
// FilterSearchbar setup
|
||||
let userFoldersFilterType = $derived(
|
||||
$userStore?.is_super_admin && $userStore.username.includes('@')
|
||||
? 'only f/*'
|
||||
: $userStore?.is_admin || $userStore?.is_super_admin
|
||||
? 'u/username and f/*'
|
||||
: undefined
|
||||
)
|
||||
let variablesFilterSchema = $derived(
|
||||
buildVariablesFilterSchema({
|
||||
paths: allPaths,
|
||||
owners: allOwners,
|
||||
labels: allLabels,
|
||||
showUserFoldersFilter: userFoldersFilterType !== undefined,
|
||||
userFoldersLabel:
|
||||
userFoldersFilterType === 'only f/*' ? 'Only f/*' : `Only u/${$userStore?.username} and f/*`
|
||||
})
|
||||
)
|
||||
let filters = useUrlSyncedFilterInstance(untrack(() => variablesFilterSchema))
|
||||
let itemFolders = $derived(
|
||||
Array.from(
|
||||
new Set(
|
||||
(variables ?? [])
|
||||
.map((x) => x.path.split('/').slice(0, 2).join('/'))
|
||||
.filter((x) => x.startsWith('f/'))
|
||||
)
|
||||
)
|
||||
.sort()
|
||||
.map((f) => f.replace(/^f\//, ''))
|
||||
)
|
||||
let folderPresets = $derived([
|
||||
...itemFolders.map((f) => ({ name: `f/${f}`, value: `path_start:\\ f/${f}/` })),
|
||||
...allLabels.map((l) => ({ name: l, value: `label:\\ ${l}` })),
|
||||
...(variablesFilterSchema.user_folders_only
|
||||
? [
|
||||
{
|
||||
name: variablesFilterSchema.user_folders_only.label ?? '?',
|
||||
value: 'user_folders_only:\\ true'
|
||||
}
|
||||
]
|
||||
: [])
|
||||
])
|
||||
let contextualVariables: ContextualVariable[] = $state([])
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
let variableEditor: VariableEditor | undefined = $state()
|
||||
let contextualVariableEditor: ContextualVariableEditor | undefined = $state()
|
||||
let loading = $state({
|
||||
contextual: true
|
||||
})
|
||||
|
||||
let deleteConfirmedCallback: (() => void) | undefined = $state(undefined)
|
||||
let deleteIsLinked = $state(false)
|
||||
let open = $derived(Boolean(deleteConfirmedCallback))
|
||||
|
||||
// Filter variables client-side for user folder filtering (admin feature)
|
||||
let filteredItems = $derived.by(() => {
|
||||
let items = variables
|
||||
if (filters.val.user_folders_only && items) {
|
||||
items = items.filter((item) => {
|
||||
if (userFoldersFilterType === 'only f/*') return item.path.startsWith('f/')
|
||||
if (userFoldersFilterType === 'u/username and f/*')
|
||||
return item.path.startsWith('f/') || item.path.startsWith(`u/${$userStore?.username}/`)
|
||||
return true
|
||||
})
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
// If relative, the dropdown is positioned relative to its button
|
||||
async function loadVariables(): Promise<void> {
|
||||
const currentFilters = filters.val
|
||||
|
||||
// Build API parameters from filters.
|
||||
// `includeDraftOnly` surfaces per-user drafts at paths that have
|
||||
// no deployed variable — appended server-side when no narrowing
|
||||
// filter is set, so the user sees AI-agent-created drafts on
|
||||
// the home page.
|
||||
const apiParams: any = {
|
||||
workspace: $workspaceStore!,
|
||||
includeDraftOnly: true
|
||||
}
|
||||
|
||||
if (currentFilters.path) {
|
||||
apiParams.path = currentFilters.path
|
||||
}
|
||||
if (currentFilters.path_start) {
|
||||
apiParams.pathStart = currentFilters.path_start
|
||||
}
|
||||
if (currentFilters.description) {
|
||||
apiParams.description = currentFilters.description
|
||||
}
|
||||
if (currentFilters.value) {
|
||||
apiParams.value = currentFilters.value
|
||||
}
|
||||
if (currentFilters.owner) {
|
||||
apiParams.pathStart = currentFilters.owner
|
||||
}
|
||||
if (currentFilters._default_) {
|
||||
apiParams.broadFilter = currentFilters._default_
|
||||
}
|
||||
if (currentFilters.label) {
|
||||
apiParams.label = currentFilters.label
|
||||
}
|
||||
|
||||
const result = (await VariableService.listVariable(apiParams)).map((x) => {
|
||||
return {
|
||||
canWrite: canWrite(x.path, x.extra_perms!, $userStore) && x.workspace_id == $workspaceStore,
|
||||
...x
|
||||
}
|
||||
})
|
||||
|
||||
// Extract unique values for autocomplete
|
||||
allPaths = Array.from(new Set(result.map((x) => x.path))).sort()
|
||||
allOwners = Array.from(
|
||||
new Set(result.map((x) => x.path.split('/').slice(0, 2).join('/')))
|
||||
).sort()
|
||||
allLabels = Array.from(
|
||||
new Set(result.flatMap((x) => [...(x.labels ?? []), ...(x.inherited_labels ?? [])]))
|
||||
).sort()
|
||||
|
||||
variables = result
|
||||
}
|
||||
|
||||
// Reload variables when filters change
|
||||
$effect(() => {
|
||||
filters.val
|
||||
if ($workspaceStore) {
|
||||
untrack(() => loadVariables())
|
||||
}
|
||||
})
|
||||
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
async function loadContextualVariables(): Promise<void> {
|
||||
contextualVariables = await VariableService.listContextualVariables({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
loading.contextual = false
|
||||
}
|
||||
|
||||
async function deleteVariable(path: string, account?: number): Promise<void> {
|
||||
if (account) {
|
||||
OauthService.disconnectAccount({ workspace: $workspaceStore!, id: account })
|
||||
}
|
||||
await VariableService.deleteVariable({ workspace: $workspaceStore!, path })
|
||||
loadVariables()
|
||||
sendUserToast(`Variable ${path} was deleted`)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($workspaceStore && $userStore) {
|
||||
untrack(() => {
|
||||
loadVariables()
|
||||
loadContextualVariables()
|
||||
})
|
||||
}
|
||||
})
|
||||
let tab: 'workspace' | 'contextual' = $state('workspace')
|
||||
|
||||
let activeFilters = $derived(hasActiveFilters(filters.val))
|
||||
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
|
||||
async function deleteContextualVariable(row: { name: string }) {
|
||||
await WorkspaceService.setEnvironmentVariable({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
name: row.name,
|
||||
value: undefined
|
||||
}
|
||||
})
|
||||
loadContextualVariables()
|
||||
sendUserToast(
|
||||
`Custom contextual variable ${row.name} was deleted. It may take up to a few minutes to update.`
|
||||
)
|
||||
setTimeout(() => {
|
||||
loadContextualVariables()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
// Deep link: #<path> opens that variable's edit drawer. Reactive rather than
|
||||
// onMount so a hash change on the already-mounted page (e.g. the AI session
|
||||
// preview re-pointing its tab) opens the drawer too. Row links pre-set
|
||||
// handledHash: their onclick already opens the drawer.
|
||||
let handledHash = ''
|
||||
$effect(() => {
|
||||
const hash = $page.url.hash
|
||||
// Only item paths are drawer targets: the same hash also carries global
|
||||
// drawers like #superadmin-settings, which must not be looked up as a variable.
|
||||
if (!/^#[ufg]\//.test(hash)) {
|
||||
// Navigating away from a drawer target must clear the tracker, or
|
||||
// re-targeting the same item later would be skipped as already handled.
|
||||
handledHash = ''
|
||||
return
|
||||
}
|
||||
if (hash === handledHash || !variableEditor) return
|
||||
handledHash = hash
|
||||
variableEditor.editVariable(hash.slice(1))
|
||||
})
|
||||
import VariablesList from '$lib/components/variables/VariablesList.svelte'
|
||||
</script>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
|
||||
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.variables}
|
||||
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
|
||||
<p class="font-bold">Unauthorized</p>
|
||||
<p>Page not available for operators</p>
|
||||
</div>
|
||||
{:else}
|
||||
<CenteredPage>
|
||||
<PageHeader
|
||||
title="Variables"
|
||||
tooltip="Save and permission strings to be reused in Scripts and Flows."
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets"
|
||||
>
|
||||
{#if showCreateButtons}
|
||||
<div class="flex flex-row justify-end">
|
||||
{#if tab == 'contextual' && ($userStore?.is_admin || $userStore?.is_super_admin)}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => contextualVariableEditor?.initNew()}
|
||||
>
|
||||
New contextual variable
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => variableEditor?.initNew()}
|
||||
>
|
||||
New variable
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</PageHeader>
|
||||
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
|
||||
|
||||
<VariableEditor
|
||||
bind:this={variableEditor}
|
||||
workspace={$workspaceStore}
|
||||
on:create={loadVariables}
|
||||
/>
|
||||
<ContextualVariableEditor
|
||||
bind:this={contextualVariableEditor}
|
||||
on:update={loadContextualVariables}
|
||||
/>
|
||||
<ShareModal
|
||||
bind:this={shareModal}
|
||||
on:change={() => {
|
||||
loadVariables()
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="flex gap-2 justify-between items-center">
|
||||
<Tabs bind:selected={tab}>
|
||||
<Tab value="workspace" label="Workspace" icon={Building} />
|
||||
<Tab value="contextual" label="Contextual" icon={DollarSign}>
|
||||
{#snippet extra()}
|
||||
<Tooltip
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets#contextual-variables"
|
||||
>
|
||||
Contextual variables are passed as environment variables when running a script and
|
||||
depends on the execution context.
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
{#if tab == 'workspace'}
|
||||
<FilterSearchbar
|
||||
class="grow max-w-[26rem]"
|
||||
schema={variablesFilterSchema}
|
||||
bind:value={filters.val}
|
||||
placeholder="Filter variables..."
|
||||
presets={folderPresets}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<TabFade key={tab}>
|
||||
{#if tab == 'workspace'}
|
||||
<div class="relative overflow-x-auto pb-40 mt-4">
|
||||
{#if !filteredItems}
|
||||
<Skeleton layout={[0.5, [2], 1]} />
|
||||
{#each new Array(3) as _}
|
||||
<Skeleton layout={[[3.5], 0.5]} />
|
||||
{/each}
|
||||
{:else if filteredItems.length == 0}
|
||||
{#if activeFilters}
|
||||
<EmptyState
|
||||
icon={SearchX}
|
||||
title="No variables found"
|
||||
description="No variable matches the current filters. Try clearing or widening them."
|
||||
/>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={DollarSign}
|
||||
title="No variables yet"
|
||||
description="Variables store values and secrets that your scripts, flows and apps can read at runtime."
|
||||
action={showCreateButtons
|
||||
? {
|
||||
label: 'Add a variable',
|
||||
icon: Plus,
|
||||
onClick: () => variableEditor?.initNew(),
|
||||
aiId: 'variables-empty-add-variable',
|
||||
aiDescription: 'Add variable'
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<DataTable size="xs">
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first class="!px-0" />
|
||||
<Cell head>Path</Cell>
|
||||
<Cell head>Value</Cell>
|
||||
<Cell head>Description</Cell>
|
||||
<Cell head>Status</Cell>
|
||||
<Cell head last actions>Actions</Cell>
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y">
|
||||
{#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft }}
|
||||
{@const hasDraft =
|
||||
getLocalDraftHint($workspaceStore, 'variable', path) ?? is_draft}
|
||||
<Row>
|
||||
<Cell class="!px-0 text-center w-12" first>
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
</Cell>
|
||||
<Cell>
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
class="break-all"
|
||||
id="edit-{path}"
|
||||
onclick={() => {
|
||||
handledHash = `#${path}`
|
||||
variableEditor?.editVariable(path)
|
||||
}}
|
||||
href="#{path}"
|
||||
>
|
||||
{path}{hasDraft ? '*' : ''}
|
||||
</a>
|
||||
<DraftBadge {draft_only} is_draft={hasDraft} />
|
||||
{#if labels?.length}
|
||||
{#each labels as label}
|
||||
<Badge
|
||||
color="blue"
|
||||
small
|
||||
class="px-1"
|
||||
title="Label: {label}"
|
||||
clickable
|
||||
onclick={() => {
|
||||
const arr = (filters.val.label ?? '').split(',').filter(Boolean)
|
||||
const idx = arr.indexOf(label)
|
||||
if (idx >= 0) arr.splice(idx, 1)
|
||||
else arr.push(label)
|
||||
const newFilters = { ...filters.val }
|
||||
if (arr.length) newFilters.label = arr.join(',')
|
||||
else delete newFilters.label
|
||||
filters.val = newFilters
|
||||
}}>{label}</Badge
|
||||
>
|
||||
{/each}
|
||||
{/if}
|
||||
<InheritedLabels labels={inherited_labels} />
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<span class="inline-flex flex-row items-center gap-2">
|
||||
<div class="text-xs break-words">
|
||||
{#if value}
|
||||
{truncate(value, 20)}
|
||||
{:else}
|
||||
∗∗∗∗
|
||||
{/if}
|
||||
</div>
|
||||
{#if is_secret}
|
||||
<Popover notClickable>
|
||||
<EyeOff size={12} />
|
||||
{#snippet text()}
|
||||
<span>This item is secret</span>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</span>
|
||||
</Cell>
|
||||
<Cell class="break-words">
|
||||
<span class="text-xs text-primary">{truncate(description ?? '', 50)} </span>
|
||||
</Cell>
|
||||
|
||||
<Cell class="text-center">
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
{#if is_linked}
|
||||
<Popover notClickable>
|
||||
<Link size={16} />
|
||||
{#snippet text()}
|
||||
<div>
|
||||
This variable is linked with a resource of the same path. They are
|
||||
deleted and renamed together.
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if account}
|
||||
<Popover notClickable>
|
||||
<RefreshCw size={16} />
|
||||
{#snippet text()}
|
||||
<div>
|
||||
This OAuth token will be kept up-to-date in the background by
|
||||
Windmill using its refresh token
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
|
||||
{#if is_refreshed}
|
||||
<div class="">
|
||||
{#if refresh_error}
|
||||
<Popover notClickable>
|
||||
<!-- isolate: confine the ping indicator's z-50 to a local stacking context
|
||||
so it can't paint over anything that scrolls past it -->
|
||||
<div
|
||||
class="relative inline-flex justify-center items-center w-4 h-4 isolate"
|
||||
>
|
||||
<Circle
|
||||
class="text-red-600 animate-ping absolute z-50 w-4 h-4 fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<Circle
|
||||
class="text-red-600 relative inline-flex fill-current "
|
||||
size={12}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#snippet text()}
|
||||
<div>
|
||||
Latest exchange of the refresh token did not succeed. Error: {refresh_error}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else if is_expired}
|
||||
<Popover notClickable>
|
||||
<Circle
|
||||
class="text-yellow-600 animate-[pulse_5s_linear_infinite] fill-current"
|
||||
size={12}
|
||||
/>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
The access_token is expired, it will get renewed the next time
|
||||
this variable is fetched or you can request is to be refreshed
|
||||
in the dropdown on the right.
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else}
|
||||
<Popover notClickable>
|
||||
<Circle
|
||||
class="text-green-600 animate-[pulse_5s_linear_infinite] fill-current"
|
||||
size={12}
|
||||
/>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
The variable was connected through OAuth and the token is not
|
||||
expired.
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell last actions shouldStopPropagation>
|
||||
<Dropdown
|
||||
items={() => {
|
||||
let owner = isOwner(path, $userStore, $workspaceStore)
|
||||
return [
|
||||
{
|
||||
displayName: 'Edit',
|
||||
icon: Pen,
|
||||
action: () => variableEditor?.editVariable(path),
|
||||
disabled: !canWrite || !showCreateButtons
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
type: 'delete',
|
||||
action: (event) => {
|
||||
if (event['shiftKey']) {
|
||||
deleteVariable(path, account)
|
||||
} else {
|
||||
deleteIsLinked = is_linked ?? false
|
||||
deleteConfirmedCallback = () => {
|
||||
deleteVariable(path, account)
|
||||
}
|
||||
}
|
||||
},
|
||||
disabled: !owner || !showCreateButtons
|
||||
},
|
||||
...(!ws_specific &&
|
||||
isDeployable(is_secret ? 'secret' : 'variable', path, deployUiSettings)
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer?.openDrawer(path, 'variable')
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
action: () => {
|
||||
shareModal?.openDrawer(path, 'variable')
|
||||
},
|
||||
icon: Shield
|
||||
},
|
||||
...(account != undefined
|
||||
? [
|
||||
{
|
||||
displayName: 'Refresh token',
|
||||
icon: RefreshCw,
|
||||
action: async () => {
|
||||
await OauthService.refreshToken({
|
||||
workspace: $workspaceStore ?? '',
|
||||
id: account ?? 0,
|
||||
requestBody: {
|
||||
path
|
||||
}
|
||||
})
|
||||
sendUserToast('Token refreshed')
|
||||
loadVariables()
|
||||
}
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}}
|
||||
/>
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if tab == 'contextual'}
|
||||
<div class="overflow-auto">
|
||||
{#if loading.contextual}
|
||||
<Skeleton layout={[0.5, [2], 1]} />
|
||||
{#each new Array(8) as _}
|
||||
<Skeleton layout={[[2.8], 0.5]} />
|
||||
{/each}
|
||||
{:else}
|
||||
<PageHeader title="Custom contextual variables" primary={false} />
|
||||
{#if contextualVariables.filter((x) => x.is_custom).length === 0}
|
||||
<EmptyState
|
||||
icon={DollarSign}
|
||||
title="No custom contextual variables yet"
|
||||
description="Custom contextual variables are passed as environment variables to every script run in this workspace."
|
||||
action={showCreateButtons && ($userStore?.is_admin || $userStore?.is_super_admin)
|
||||
? {
|
||||
label: 'Add a contextual variable',
|
||||
icon: Plus,
|
||||
onClick: () => contextualVariableEditor?.initNew(),
|
||||
aiId: 'variables-empty-add-contextual-variable',
|
||||
aiDescription: 'Add contextual variable'
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
{:else}
|
||||
<TableSimple
|
||||
headers={['Name', 'Value']}
|
||||
data={contextualVariables.filter((x) => x.is_custom)}
|
||||
keys={['name', 'value']}
|
||||
getRowActions={$userStore?.is_admin || $userStore?.is_super_admin
|
||||
? (row) => {
|
||||
return [
|
||||
{
|
||||
displayName: 'Edit',
|
||||
action: () => contextualVariableEditor?.editVariable(row.name, row.value)
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
type: 'delete',
|
||||
action: () => {
|
||||
deleteContextualVariable(row)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
{/if}
|
||||
<PageHeader title="Contextual variables" primary={false} />
|
||||
<TableSimple
|
||||
headers={['Name', 'Example of value', 'Description']}
|
||||
data={contextualVariables.filter((x) => !x.is_custom)}
|
||||
keys={['name', 'value', 'description']}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</TabFade>
|
||||
</CenteredPage>
|
||||
{/if}
|
||||
|
||||
<ConfirmationModal
|
||||
{open}
|
||||
title="Remove variable"
|
||||
confirmationText="Remove"
|
||||
trashbin
|
||||
on:canceled={() => {
|
||||
deleteConfirmedCallback = undefined
|
||||
}}
|
||||
on:confirmed={() => {
|
||||
if (deleteConfirmedCallback) {
|
||||
deleteConfirmedCallback()
|
||||
}
|
||||
deleteConfirmedCallback = undefined
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
<span>Are you sure you want to remove this variable?</span>
|
||||
{#if deleteIsLinked}
|
||||
<Alert type="warning" title="Linked resource">
|
||||
This variable is linked with a resource of the same path. The linked resource will also be
|
||||
deleted.
|
||||
</Alert>
|
||||
{/if}
|
||||
<Alert type="info" title="Bypass confirmation">
|
||||
<div>
|
||||
You can press
|
||||
<Badge color="dark-gray">SHIFT</Badge>
|
||||
while removing a variable to bypass confirmation.
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
<VariablesList />
|
||||
|
||||
@@ -1,579 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { run } from 'svelte/legacy'
|
||||
|
||||
import {
|
||||
WebsocketTriggerService,
|
||||
WorkspaceService,
|
||||
type WebsocketTrigger,
|
||||
type TriggerMode,
|
||||
type WorkspaceDeployUISettings
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
canWrite,
|
||||
displayDate,
|
||||
getLocalSetting,
|
||||
sendUserToast,
|
||||
storeLocalSetting,
|
||||
removeTriggerKindIfUnused,
|
||||
capitalize
|
||||
} from '$lib/utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { base } from '$app/paths'
|
||||
import { page } from '$app/stores'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { enterpriseLicense, usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
|
||||
import { Unplug, Code, Eye, Pen, Plus, Shield, Trash, Circle, FileUp, Pause } from 'lucide-svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import ListFilters from '$lib/components/home/ListFilters.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import WebsocketTriggerEditor from '$lib/components/triggers/websocket/WebsocketTriggerEditor.svelte'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
|
||||
import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte'
|
||||
|
||||
type TriggerW = WebsocketTrigger & { canWrite: boolean }
|
||||
|
||||
let triggers: TriggerW[] = $state([])
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
let loading = $state(true)
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
async function loadTriggers(): Promise<void> {
|
||||
triggers = (
|
||||
await WebsocketTriggerService.listWebsocketTriggers({
|
||||
workspace: $workspaceStore!,
|
||||
includeDraftOnly: true
|
||||
})
|
||||
).map((x) => {
|
||||
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
|
||||
})
|
||||
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'websockets', $usedTriggerKinds)
|
||||
loading = false
|
||||
}
|
||||
|
||||
let interval = setInterval(async () => {
|
||||
try {
|
||||
const newTriggers = await WebsocketTriggerService.listWebsocketTriggers({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
for (let i = 0; i < triggers.length; i++) {
|
||||
const newTrigger = newTriggers.find((x) => x.path === triggers[i].path)
|
||||
if (newTrigger) {
|
||||
triggers[i] = {
|
||||
...triggers[i],
|
||||
error: newTrigger.error,
|
||||
last_server_ping: newTrigger.last_server_ping,
|
||||
mode: newTrigger.mode,
|
||||
server_id: newTrigger.server_id
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(interval)
|
||||
})
|
||||
|
||||
async function onToggleMode(path: string, mode: TriggerMode): Promise<boolean> {
|
||||
let committed = false
|
||||
try {
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
WebsocketTriggerService.setWebsocketTriggerMode({
|
||||
path,
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { mode, force }
|
||||
}),
|
||||
'websocket trigger'
|
||||
)
|
||||
if (ok) {
|
||||
sendUserToast(`${capitalize(mode)} websocket trigger ${path}`)
|
||||
loadTriggers()
|
||||
}
|
||||
committed = ok
|
||||
} catch (err) {
|
||||
sendUserToast(
|
||||
`Cannot ` +
|
||||
(mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend') +
|
||||
` websocket trigger: ${err.body}`,
|
||||
true
|
||||
)
|
||||
loadTriggers()
|
||||
}
|
||||
return committed
|
||||
}
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore && $userStore) {
|
||||
loadTriggers()
|
||||
}
|
||||
})
|
||||
let websocketTriggerEditor: WebsocketTriggerEditor | undefined = $state()
|
||||
|
||||
let hashHandled = false
|
||||
$effect(() => {
|
||||
if (!hashHandled && triggers.length > 0 && websocketTriggerEditor) {
|
||||
let hash = $page.url.hash
|
||||
if (hash.length > 1) {
|
||||
let path = hash.slice(1)
|
||||
let trigger = triggers.find((t) => t.path === path)
|
||||
if (trigger) {
|
||||
hashHandled = true
|
||||
websocketTriggerEditor?.openEdit(path, trigger.is_flow)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let filteredItems: (TriggerW & { marked?: any })[] | undefined = $state([])
|
||||
let items: typeof filteredItems | undefined = $state([])
|
||||
let preFilteredItems: typeof filteredItems | undefined = $state([])
|
||||
let filter = $state('')
|
||||
let ownerFilter: string | undefined = $state(undefined)
|
||||
let nbDisplayed = $state(15)
|
||||
|
||||
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
|
||||
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
|
||||
let selectedFilterKind = $state(
|
||||
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
|
||||
)
|
||||
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
|
||||
|
||||
run(() => {
|
||||
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
|
||||
})
|
||||
run(() => {
|
||||
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
|
||||
})
|
||||
|
||||
function filterItemsPathsBaseOnUserFilters(
|
||||
item: TriggerW,
|
||||
selectedFilterKind: 'trigger' | 'script_flow',
|
||||
filterUserFolders: boolean
|
||||
) {
|
||||
if ($workspaceStore == 'admins') return true
|
||||
if (filterUserFolders) {
|
||||
if (selectedFilterKind === 'trigger') {
|
||||
return (
|
||||
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
!item.script_path.startsWith('u/') ||
|
||||
item.script_path.startsWith('u/' + $userStore?.username + '/')
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
run(() => {
|
||||
preFilteredItems =
|
||||
ownerFilter != undefined
|
||||
? selectedFilterKind === 'trigger'
|
||||
? triggers?.filter(
|
||||
(x) =>
|
||||
x.path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter(
|
||||
(x) =>
|
||||
x.script_path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
: triggers?.filter((x) =>
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
|
||||
)
|
||||
})
|
||||
|
||||
run(() => {
|
||||
if ($workspaceStore) {
|
||||
ownerFilter = undefined
|
||||
}
|
||||
})
|
||||
|
||||
let owners = $derived(
|
||||
selectedFilterKind === 'trigger'
|
||||
? Array.from(
|
||||
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
: Array.from(
|
||||
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
)
|
||||
|
||||
run(() => {
|
||||
items = filter !== '' ? filteredItems : preFilteredItems
|
||||
})
|
||||
|
||||
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
|
||||
setQuery(
|
||||
TRIGGER_PATH_KIND_FILTER_SETTING,
|
||||
selectedFilterKind,
|
||||
window.location.hash || undefined
|
||||
).then(() => {
|
||||
setQuery(
|
||||
FILTER_USER_FOLDER_SETTING_NAME,
|
||||
String(filterUserFolders),
|
||||
window.location.hash || undefined
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function loadQueryFilters() {
|
||||
let url = new URL(window.location.href)
|
||||
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
|
||||
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
|
||||
if (queryFilterKind) {
|
||||
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
|
||||
}
|
||||
if (queryFilterUserFolders) {
|
||||
filterUserFolders = queryFilterUserFolders == 'true'
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadQueryFilters()
|
||||
})
|
||||
|
||||
run(() => {
|
||||
updateQueryFilters(selectedFilterKind, filterUserFolders)
|
||||
})
|
||||
import TriggerList from '$lib/components/triggers/TriggerList.svelte'
|
||||
</script>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<WebsocketTriggerEditor onUpdate={loadTriggers} bind:this={websocketTriggerEditor} />
|
||||
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={preFilteredItems}
|
||||
bind:filteredItems
|
||||
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
|
||||
/>
|
||||
|
||||
<CenteredPage>
|
||||
<PageHeader
|
||||
title="WebSocket triggers"
|
||||
tooltip="Windmill can listen to WebSocket events and trigger scripts or flows based on them."
|
||||
>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => websocketTriggerEditor?.openNew(false)}
|
||||
>
|
||||
New WebSocket trigger
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{#if isCloudHosted()}
|
||||
<Alert title="Not compatible with multi-tenant cloud" type="warning">
|
||||
WebSocket triggers are disabled in the multi-tenant cloud.
|
||||
</Alert>
|
||||
<div class="py-4"></div>
|
||||
{/if}
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<div class="w-full pb-4 pt-6">
|
||||
<input type="text" placeholder="Search WS triggers" bind:value={filter} class="search-item" />
|
||||
<div class="flex flex-row items-center gap-2 mt-2">
|
||||
<div class="text-xs font-semibold text-emphasis shrink-0"> Filter by path of </div>
|
||||
<ToggleButtonGroup bind:selected={selectedFilterKind}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="trigger" label="WS trigger" icon={Unplug} {item} />
|
||||
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
|
||||
|
||||
<div class="flex flex-row items-center justify-end gap-4">
|
||||
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
|
||||
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
|
||||
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={filterUserFolders}
|
||||
options={{ right: `Only u/${$userStore.username} and f/*` }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if loading}
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[6], 0.4]} />
|
||||
{/each}
|
||||
{:else if !triggers?.length}
|
||||
<EmptyState
|
||||
icon={Unplug}
|
||||
title="No WebSocket triggers yet"
|
||||
description="Windmill can listen to WebSocket events and trigger scripts or flows based on them."
|
||||
action={{
|
||||
label: 'Add a WebSocket trigger',
|
||||
icon: Plus,
|
||||
onClick: () => websocketTriggerEditor?.openNew(false),
|
||||
aiId: 'websocket-triggers-empty-add',
|
||||
aiDescription: 'Add WebSocket trigger'
|
||||
}}
|
||||
/>
|
||||
{:else if items?.length}
|
||||
<div class="border rounded-md divide-y">
|
||||
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, url, is_flow, extra_perms, canWrite, marked, error, last_server_ping, server_id, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
|
||||
{@const hasDraft =
|
||||
getLocalDraftHint($workspaceStore, 'trigger_websocket', path) ?? is_draft}
|
||||
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
|
||||
{@const ping = last_server_ping ? new Date(last_server_ping) : undefined}
|
||||
{@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000}
|
||||
{@const effectiveMode = draft_only ? 'disabled' : mode}
|
||||
{@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'}
|
||||
|
||||
<div
|
||||
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
|
||||
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
|
||||
>
|
||||
<div class="w-full flex gap-5 items-center">
|
||||
<RowIcon kind={is_flow ? 'flow' : 'script'} />
|
||||
|
||||
<a
|
||||
href="#{path}"
|
||||
onclick={() => websocketTriggerEditor?.openEdit(path, is_flow)}
|
||||
class="min-w-0 grow hover:underline decoration-gray-400"
|
||||
>
|
||||
<div class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate">
|
||||
{#if marked}
|
||||
<span class="text-xs">
|
||||
{@html marked}
|
||||
</span>
|
||||
{:else}
|
||||
{url.startsWith('$script:')
|
||||
? 'URL: ' + url.replace('$script:', 'result of script ')
|
||||
: url.startsWith('$flow:')
|
||||
? 'URL: ' + url.replace('$flow:', 'result of flow ')
|
||||
: url}
|
||||
{/if}{hasDraft ? '*' : ''}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left font-light">
|
||||
{path}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left font-light">
|
||||
runnable: {script_path}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="hidden lg:flex flex-row gap-1 items-center">
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
{#if labels?.length}
|
||||
{#each labels as label}
|
||||
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="w-10">
|
||||
{#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle
|
||||
class="text-red-600 animate-ping absolute inline-flex fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<Circle class="text-red-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
{#if enabled}
|
||||
{#if !server_id}
|
||||
WebSocket is starting...
|
||||
{:else}
|
||||
WebSocket is not connected{error ? ': ' + error : ''}
|
||||
{/if}
|
||||
{:else}
|
||||
WebSocket was disabled because of an error: {error}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else if enabled}
|
||||
<Popover notClickable>
|
||||
<span class="flex h-4 w-4">
|
||||
<Circle class="text-green-600 relative inline-flex fill-current" size={12} />
|
||||
</span>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
WebSocket is connected{!server_id ? ' (shutting down...)' : ''}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 shrink-0 min-w-[8rem]">
|
||||
<DraftBadge {draft_only} is_draft={hasDraft} />
|
||||
<TriggerModeToggle
|
||||
disabled={draft_only}
|
||||
title={draft_only
|
||||
? 'Draft only: deploy the trigger to enable it'
|
||||
: hasDraft
|
||||
? 'Enables/disables the deployed trigger; the draft is not affected'
|
||||
: undefined}
|
||||
onToggleMode={(newMode) => onToggleMode(path, newMode)}
|
||||
triggerMode={effectiveMode}
|
||||
includeModalConfig={{
|
||||
triggerPath: path,
|
||||
triggerKind: 'websocket',
|
||||
runnableConfig: {
|
||||
path: script_path,
|
||||
kind: is_flow ? 'flow' : 'script',
|
||||
retry,
|
||||
errorHandlerPath: error_handler_path,
|
||||
errorHandlerArgs: error_handler_args
|
||||
}
|
||||
}}
|
||||
{canWrite}
|
||||
hideToggleLabels
|
||||
hideDropdown
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center justify-end">
|
||||
<Button
|
||||
on:click={() => websocketTriggerEditor?.openEdit(path, is_flow)}
|
||||
size="xs"
|
||||
startIcon={canWrite
|
||||
? { icon: Pen }
|
||||
: {
|
||||
icon: Eye
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
{canWrite ? 'Edit' : 'View'}
|
||||
</Button>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
|
||||
icon: Eye,
|
||||
action: () => {
|
||||
goto(href)
|
||||
}
|
||||
},
|
||||
...(canWrite && !draft_only && mode !== 'suspended'
|
||||
? [
|
||||
{
|
||||
displayName: 'Suspend job execution',
|
||||
icon: Pause,
|
||||
action: () => {
|
||||
onToggleMode(path, 'suspended')
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: canWrite ? 'Edit' : 'View',
|
||||
icon: canWrite ? Pen : Eye,
|
||||
action: () => {
|
||||
websocketTriggerEditor?.openEdit(path, is_flow)
|
||||
}
|
||||
},
|
||||
...(isDeployable('trigger', path, deployUiSettings)
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer?.openDrawer(path, 'trigger', {
|
||||
triggers: {
|
||||
kind: 'websockets'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Audit logs',
|
||||
icon: Eye,
|
||||
href: `${base}/audit_logs?resource=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
action: () => {
|
||||
shareModal?.openDrawer(path, 'websocket_trigger')
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
type: 'delete',
|
||||
icon: Trash,
|
||||
disabled: !canWrite,
|
||||
action: async () => {
|
||||
await WebsocketTriggerService.deleteWebsocketTrigger({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path
|
||||
})
|
||||
loadTriggers()
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-between items-baseline">
|
||||
<div
|
||||
class="flex flex-wrap text-[0.7em] text-primary gap-1 items-center justify-end truncate pr-2"
|
||||
>{#if edited_by}<div class="truncate">edited by {edited_by}</div>{/if}<div
|
||||
class="truncate">{edited_by ? 'the ' : ''}{displayDate(edited_at)}</div
|
||||
></div
|
||||
></div
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<NoItemFound />
|
||||
{/if}
|
||||
</div>
|
||||
{#if items && items?.length > 15 && nbDisplayed < items.length}
|
||||
<span class="text-xs"
|
||||
>{nbDisplayed} items out of {items.length}
|
||||
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
|
||||
>
|
||||
{/if}
|
||||
</CenteredPage>
|
||||
|
||||
<ShareModal
|
||||
bind:this={shareModal}
|
||||
on:change={() => {
|
||||
loadTriggers()
|
||||
}}
|
||||
/>
|
||||
<TriggerList triggerKind="websocket" />
|
||||
|
||||
Reference in New Issue
Block a user