Files
windmill/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte
T
Diego ImbertandRuben Fiszel ebb3048ca0 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>
2026-09-24 15:14:59 +02:00

1019 lines
33 KiB
Svelte

<script lang="ts">
import { Alert, Button, TabContent } from '$lib/components/common'
import {
clearPageDrawerAnchor,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { TRIGGER_PAGES } from '$lib/components/sessions/previewPaths'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import Path from '$lib/components/Path.svelte'
import Required from '$lib/components/Required.svelte'
import TriggerRunnablePicker from '$lib/components/triggers/TriggerRunnablePicker.svelte'
import {
PostgresTriggerService,
type ErrorHandler,
type Language,
type Relations,
type Retry,
type TriggerMode
} from '$lib/gen'
import { usedTriggerKinds } from '$lib/stores'
import { canWrite, emptyString, emptyStringTrimmed, sendUserToast } from '$lib/utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import Section from '$lib/components/Section.svelte'
import { Loader2 } from 'lucide-svelte'
import Label from '$lib/components/Label.svelte'
import ResourcePicker from '$lib/components/ResourcePicker.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import PublicationPicker from './PublicationPicker.svelte'
import SlotPicker from './SlotPicker.svelte'
import { random_adj } from '$lib/components/random_positive_adjetive'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import Tab from '$lib/components/common/tabs/Tab.svelte'
import RelationPicker from './RelationPicker.svelte'
import { invalidRelations, savePostgresTriggerFromCfg } from './utils'
import CheckPostgresRequirement from './CheckPostgresRequirement.svelte'
import { base } from '$lib/base'
import { untrack, type Snippet } from 'svelte'
import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte'
import PermissionedAsLine from '../PermissionedAsLine.svelte'
import TestingBadge from '../testingBadge.svelte'
import { getHandlerType, handleConfigChange, type Trigger } from '../utils'
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
import { fade } from 'svelte/transition'
import MultiSelect from '$lib/components/select/MultiSelect.svelte'
import { safeSelectItems } from '$lib/components/select/utils.svelte'
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
import { deepEqual } from 'fast-equals'
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte'
import { capitalize } from '$lib/utils'
import {
useOperatingUser,
useOperatingWorkspace,
useOperatingWorkspaceHref
} from '$lib/components/operatingWorkspace.svelte'
interface Props {
useDrawer?: boolean
/** With `useDrawer`, render the drawer's content in place, filling the parent, with no drawer or close button. */
inline?: boolean
/** With `inline`, closes whatever hosts the editor; the header has a close button only when set. */
onClose?: () => void
description?: Snippet | undefined
hideTarget?: boolean
isEditor?: boolean
hideTooltips?: boolean
allowDraft?: boolean
trigger?: Trigger
isDeployed?: boolean
cloudDisabled?: boolean
customLabel?: Snippet
onConfigChange?: (cfg: Record<string, any>, saveDisabled: boolean, updated: boolean) => void
onCaptureConfigChange?: (cfg: Record<string, any>, isValid: boolean) => void
onUpdate?: (path?: string) => void
onDelete?: () => void
onReset?: () => void
}
let {
useDrawer = true,
inline = false,
onClose = undefined,
description = undefined,
hideTarget = false,
isEditor = false,
hideTooltips = false,
allowDraft = false,
trigger = undefined,
isDeployed = false,
cloudDisabled = false,
customLabel = undefined,
onConfigChange = undefined,
onCaptureConfigChange = undefined,
onUpdate = undefined,
onDelete = undefined,
onReset = undefined
}: Props = $props()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const operatingHref = useOperatingWorkspaceHref()
const wsId = $derived($operatingWorkspace)
let drawer: Drawer | undefined = $state(undefined)
let is_flow: boolean = $state(false)
let initialPath = $state('')
let edit: boolean = $state(true)
let itemKind: 'flow' | 'script' = $state('script')
let script_path: string = $state('')
let initialScriptPath: string = $state('')
let fixedScriptPath: string = $state('')
let path: string = $state('')
let pathError = $state('')
let dirtyPath: boolean = $state(false)
let permsPath = $state<string | undefined>(undefined)
let permsForWrite = $state<Record<string, boolean> | undefined>(undefined)
// The acting user in the operating workspace arrives asynchronously, and an unknown user
// refuses — so the editor stays read-only until the lookup lands, which is the safe answer.
const can_write = $derived(
permsPath === undefined ? true : canWrite(permsPath, permsForWrite ?? {}, actingUser)
)
let drawerLoading: boolean = $state(true)
let showLoading: boolean = $state(false)
let postgres_resource_path: string = $state('')
let publication_name: string = $state('')
let replication_slot_name: string = $state('')
let relations: Relations[] | undefined = $state([])
let transaction_to_track: string[] = $state([])
let language: Language = 'Typescript'
let loading = $state(false)
let postgresVersion: string = $state('')
let loadingPostgres: boolean = $state(false)
type actions = 'create' | 'get'
let selectedPublicationAction: actions | undefined = $state(undefined)
let selectedSlotAction: actions | undefined = $state(undefined)
let publicationItems: string[] = $state([])
let transactionType: string[] = $state(['Insert', 'Update', 'Delete'])
let tab: 'advanced' | 'basic' = $state('basic')
let basic_mode = $derived(tab === 'basic')
let initialConfig: Record<string, any> | undefined = undefined
let deploymentLoading = $state(false)
let permissionedAs = $state<string | undefined>(undefined)
let selectedPermissionedAs = $state<string | undefined>(undefined)
let preservePermissionedAs = $state(false)
let creatingSlot: boolean = $state(false)
let creatingPublication: boolean = $state(false)
let pg14: boolean = $derived(postgresVersion.startsWith('14'))
let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler')
let errorHandlerSelected: ErrorHandler = $state('slack')
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let retry: Retry | undefined = $state()
let mode = $state<TriggerMode>('enabled')
let suspendedJobsModal = $state<TriggerSuspendedJobsModal | null>(null)
let originalConfig = $state<Record<string, any> | undefined>(undefined)
let hasChanged = $derived(!deepEqual(getSaveCfg(), originalConfig ?? {}))
const errorMessage = $derived.by(() => {
if (relations && relations.length > 0) {
return invalidRelations(relations, {
showError: true,
trackSchemaTableError: true
})
}
return ''
})
function isAdvancedTab(t: 'advanced' | 'basic'): boolean {
return t === 'advanced'
}
function isBasicTab(t: 'advanced' | 'basic'): boolean {
return t === 'basic'
}
const isValid = $derived(
!emptyString(postgres_resource_path) &&
transaction_to_track.length > 0 &&
((isAdvancedTab(tab) &&
!emptyString(replication_slot_name) &&
!emptyString(publication_name)) ||
(isBasicTab(tab) && (!relations || relations.length > 0))) &&
!errorMessage
)
const postgresConfig = $derived.by(getSaveCfg)
const draftSync = useTriggerDraftSync({
itemKind: 'trigger_postgres',
path: () => initialPath,
workspace: () => wsId,
drawerLoading: () => drawerLoading,
getCfg: () => postgresConfig,
applyCfg: loadTriggerConfig,
deployed: () => originalConfig
})
const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({}))
const saveDisabled = $derived(
pathError !== '' ||
emptyString(script_path) ||
drawerLoading ||
!can_write ||
!isValid ||
!hasChanged
)
async function createPublication() {
try {
creatingPublication = true
const message = await PostgresTriggerService.createPostgresPublication({
path: postgres_resource_path,
publication: publication_name as string,
workspace: wsId!,
requestBody: {
transaction_to_track: transaction_to_track,
table_to_track: relations
}
})
sendUserToast(message)
} catch (error) {
sendUserToast(error.body, true)
} finally {
creatingPublication = false
}
}
async function createSlot() {
try {
creatingSlot = true
const message = await PostgresTriggerService.createPostgresReplicationSlot({
path: postgres_resource_path,
workspace: wsId!,
requestBody: {
name: replication_slot_name
}
})
sendUserToast(message)
} catch (error) {
sendUserToast(error.body, true)
} finally {
creatingSlot = false
}
}
$effect(() => {
is_flow = itemKind === 'flow'
})
export async function openEdit(
ePath: string,
isFlow: boolean,
defaultConfig?: Record<string, any>,
fixedScriptPath_?: string
) {
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
drawerLoading = true
try {
drawer?.openDrawer()
setPageDrawerAnchor(TRIGGER_PAGES.postgres.path, ePath)
initialPath = ePath
itemKind = isFlow ? 'flow' : 'script'
edit = true
dirtyPath = false
selectedPublicationAction = 'get'
selectedSlotAction = 'get'
selectedPublicationAction = selectedPublicationAction
selectedSlotAction = selectedSlotAction
relations = []
transaction_to_track = []
tab = 'basic'
fixedScriptPath = fixedScriptPath_ ?? ''
const { overlay: draftOverlay, noDeployed } = await loadTrigger(defaultConfig)
// Draft-only triggers have no deployed row, so saving must CREATE (update 404s).
edit = !noDeployed
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
if (draftOverlay) loadTriggerConfig(draftOverlay)
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load postgres trigger: ${err.body}`, true)
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
showLoading = false
}
}
export async function openNew(
nis_flow: boolean,
fixedScriptPath_?: string,
defaultValues?: Record<string, any>,
newDraft?: boolean
) {
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
drawerLoading = true
try {
selectedPublicationAction = 'create'
selectedSlotAction = 'create'
tab = 'basic'
drawer?.openDrawer()
is_flow = nis_flow
itemKind = nis_flow ? 'flow' : 'script'
initialScriptPath = defaultValues?.script_path ?? ''
fixedScriptPath = fixedScriptPath_ ?? ''
script_path = fixedScriptPath
path = defaultValues?.path ?? ''
initialPath = ''
postgres_resource_path = defaultValues?.postgres_resource_path ?? ''
edit = false
dirtyPath = false
publication_name = defaultValues?.publication_name ?? `windmill_publication_${random_adj()}`
replication_slot_name =
defaultValues?.replication_slot_name ?? `windmill_replication_${random_adj()}`
transaction_to_track = defaultValues?.publication?.transaction_to_track || [
'Insert',
'Update',
'Delete'
]
relations = defaultValues?.publication?.table_to_track || [
{
schema_name: 'public',
table_to_track: []
}
]
error_handler_path = defaultValues?.error_handler_path ?? undefined
error_handler_args = defaultValues?.error_handler_args ?? {}
retry = defaultValues?.retry ?? undefined
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
mode = defaultValues?.mode ?? 'enabled'
permissionedAs = undefined
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
showLoading = false
}
}
function getSaveCfg(): Record<string, any> {
const cfg = {
script_path: script_path,
is_flow: is_flow,
path: path,
postgres_resource_path: postgres_resource_path,
publication_name: edit || tab === 'advanced' ? publication_name : undefined,
replication_slot_name: edit || tab === 'advanced' ? replication_slot_name : undefined,
publication:
!edit || tab === 'basic'
? {
transaction_to_track: transaction_to_track,
table_to_track: relations
}
: undefined,
error_handler_path,
error_handler_args,
retry,
mode,
permissioned_as: selectedPermissionedAs,
preserve_permissioned_as: preservePermissionedAs || undefined
}
return cfg
}
async function loadTriggerConfig(cfg?: Record<string, any>): Promise<void> {
// The loaded trigger says what it runs; an opener's `isFlow` is only its guess.
if (cfg?.is_flow !== undefined) itemKind = cfg.is_flow ? 'flow' : 'script'
script_path = cfg?.script_path
initialScriptPath = cfg?.script_path
is_flow = cfg?.is_flow
path = cfg?.path
postgres_resource_path = cfg?.postgres_resource_path
publication_name = cfg?.publication_name
replication_slot_name = cfg?.replication_slot_name
permsPath = path
permsForWrite = cfg?.extra_perms
transaction_to_track = [...cfg?.publication?.transaction_to_track]
relations = cfg?.publication?.table_to_track ?? []
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
retry = cfg?.retry
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
mode = cfg?.mode ?? 'enabled'
permissionedAs = cfg?.permissioned_as
selectedPermissionedAs = cfg?.permissioned_as
preservePermissionedAs = !!cfg?.permissioned_as
}
/**
* Apply the deployed config to the form, then return the saved-draft overlay
* so the caller captures `originalConfig` BEFORE applying the draft (see
* `NatsTriggerEditorInner.loadTrigger`). Postgres wrinkle: the publication is
* keyed by resource/name the draft may have changed, so it's fetched using
* the effective values to reflect the draft's view.
*/
async function loadTrigger(
defaultConfig?: Record<string, any>
): Promise<{ overlay: Record<string, any> | undefined; noDeployed: boolean }> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
if (defaultConfig?.publication) {
transaction_to_track = [...defaultConfig.publication.transaction_to_track]
relations = defaultConfig.publication.table_to_track ?? []
}
return { overlay: undefined, noDeployed: false }
}
const s = await PostgresTriggerService.getPostgresTrigger({
workspace: wsId!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
// Draft-only: synthesized stand-in, no row, so saving must CREATE.
const noDeployed = !!(s as any)?.no_deployed
// Deployed config + publication become the `originalConfig` baseline.
const deployedPublication = await PostgresTriggerService.getPostgresPublication({
path: deployedTrigger.postgres_resource_path,
workspace: wsId!,
publication: deployedTrigger.publication_name
})
loadTriggerConfig({ ...deployedTrigger, publication: deployedPublication })
if (!draftFromBackend) return { overlay: undefined, noDeployed }
// Refetch the publication for the draft's keys if they differ.
const effective = { ...deployedTrigger, ...draftFromBackend }
const effectivePublication =
effective.postgres_resource_path === deployedTrigger.postgres_resource_path &&
effective.publication_name === deployedTrigger.publication_name
? deployedPublication
: await PostgresTriggerService.getPostgresPublication({
path: effective.postgres_resource_path,
workspace: wsId!,
publication: effective.publication_name
})
return { overlay: { ...effective, publication: effectivePublication }, noDeployed }
}
function getCaptureConfig() {
return {
postgres_resource_path,
basic_mode,
publication:
!edit || tab === 'basic'
? {
transaction_to_track: transaction_to_track,
table_to_track: relations
}
: undefined,
publication_name: edit || tab !== 'basic' ? publication_name : undefined,
replication_slot_name: edit || tab !== 'basic' ? replication_slot_name : undefined,
path
}
}
async function updateTrigger(): Promise<void> {
const cfg = postgresConfig
if (!cfg) {
return
}
const previousPath = initialPath
deploymentLoading = true
const isSaved = await savePostgresTriggerFromCfg(
initialPath,
cfg,
edit,
wsId!,
usedTriggerKinds
)
if (isSaved) {
draftSync.discard(previousPath, getSaveCfg())
onUpdate?.(path)
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
initialPath = cfg.path
initialScriptPath = cfg.script_path
if (mode !== 'suspended') {
drawer?.closeDrawer()
}
}
deploymentLoading = false
}
const getTemplateScript = async () => {
if (!relations || relations.length === 0 || emptyString(postgres_resource_path)) {
sendUserToast('You must pick a database resource and choose at least one schema', true)
return
}
try {
loading = true
let templateId = await PostgresTriggerService.createTemplateScript({
workspace: wsId!,
requestBody: {
relations,
language,
postgres_resource_path
}
})
window.open(operatingHref(`${base}/scripts/add?id=${templateId}`))
loading = false
} catch (error) {
loading = false
sendUserToast(error.body, true)
}
}
async function handleToggleMode(newMode: TriggerMode) {
const previousMode = mode
mode = newMode
if (!trigger?.draftConfig) {
const ok = await withForkConflictRetry(
(force) =>
PostgresTriggerService.setPostgresTriggerMode({
path: initialPath,
workspace: wsId ?? '',
requestBody: { mode: newMode, force }
}),
'postgres trigger'
)
if (!ok) {
mode = previousMode
return
}
sendUserToast(`${capitalize(newMode)} postgres trigger ${initialPath}`)
onUpdate?.(initialPath)
}
if (originalConfig) {
originalConfig['mode'] = newMode
}
}
$effect(() => {
const args = [captureConfig, isValid] as const
untrack(() => onCaptureConfigChange?.(...args))
})
$effect(() => {
if (!drawerLoading) {
handleConfigChange(postgresConfig, initialConfig, saveDisabled, edit, onConfigChange)
}
})
$effect(() => {
if (postgres_resource_path) {
loadingPostgres = true
PostgresTriggerService.getPostgresVersion({
workspace: wsId!,
path: postgres_resource_path
})
.then((version: string) => {
postgresVersion = version
})
.catch((error: any) => {
sendUserToast(error.body, true)
})
.finally(() => {
loadingPostgres = false
})
}
})
</script>
{#if mode === 'suspended'}
<TriggerSuspendedJobsModal
bind:this={suspendedJobsModal}
triggerPath={path}
triggerKind="postgres"
{hasChanged}
onToggleMode={handleToggleMode}
runnableConfig={{
path: script_path,
kind: itemKind,
retry,
errorHandlerPath: error_handler_path,
errorHandlerArgs: error_handler_args
}}
/>
{/if}
{#snippet drawerBody()}
<DrawerContent
hideClose={inline && !onClose}
fullScreen={!inline}
bannerReserved={draftSync.hasBaseline}
title={edit
? can_write
? `Edit Postgres trigger ${initialPath}`
: `Postgres trigger ${initialPath}`
: 'New Postgres trigger'}
on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())}
>
{#snippet actions()}{@render actionsSnippet()}{/snippet}
{#snippet banner()}
<LocalDraftBanner
show={draftSync.hasDraft}
getDeployed={() => draftSync.deployed}
reserveSpace={draftSync.hasBaseline}
getCurrent={() => draftSync.current}
onDiscard={() => draftSync.resetToDeployed(initialPath)}
disabled={!can_write}
/>
{/snippet}
{@render content()}
</DrawerContent>
{/snippet}
{#if useDrawer && inline}
{@render drawerBody()}
{:else if useDrawer}
<Drawer
size="800px"
bind:this={drawer}
on:close={() => clearPageDrawerAnchor(TRIGGER_PAGES.postgres.path)}
>
{@render drawerBody()}
</Drawer>
{:else}
<Section label={!customLabel ? 'Postgres trigger' : ''} headerClass="grow min-w-0 h-[30px]">
{#snippet header()}
{#if customLabel}
{@render customLabel()}
{/if}
{/snippet}
{#snippet action()}
{@render actionsSnippet()}
{/snippet}
{@render content()}
</Section>
{/if}
{#snippet actionsSnippet()}
{#if !drawerLoading}
<TriggerEditorToolbar
triggerKind="postgres"
triggerPath={initialPath}
{trigger}
permissions={drawerLoading || !can_write ? 'none' : 'create'}
{saveDisabled}
{allowDraft}
{edit}
isLoading={deploymentLoading}
{isDeployed}
onUpdate={updateTrigger}
{onReset}
{onDelete}
{mode}
onToggleMode={handleToggleMode}
{suspendedJobsModal}
{cloudDisabled}
/>
{/if}
{/snippet}
{#snippet content()}
{#if drawerLoading}
{#if showLoading}
<div class="flex flex-col items-center justify-center h-full w-full">
<Loader2 size="50" class="animate-spin" />
<p>Loading...</p>
</div>
{/if}
{:else}
<PermissionedAsLine
{permissionedAs}
{path}
onPermissionedAsChange={(pa, preserve) => {
selectedPermissionedAs = pa
preservePermissionedAs = preserve
}}
/>
<div class="flex flex-col gap-4">
{#if description}
{@render description()}
{/if}
{#if !hideTooltips}
<Alert title="Info" type="info" size="xs">
{#if edit}
Changes can take up to 30 seconds to take effect.
{:else}
New postgres triggers can take up to 30 seconds to start listening.
{/if}
</Alert>
{/if}
</div>
<div class="flex flex-col gap-12 mt-6">
{#if mode === 'suspended'}
<TriggerSuspendedJobsAlert {suspendedJobsModal} />
{/if}
<Label label="Path">
<Path
workspaceOverride={wsId}
bind:dirty={dirtyPath}
bind:error={pathError}
bind:path
{initialPath}
checkInitialPathExistence={!edit}
namePlaceholder="postgres_trigger"
kind="postgres_trigger"
disabled={!can_write}
disableEditing={!can_write}
/>
</Label>
{#if !hideTarget}
<Section label="Runnable">
<TriggerRunnablePicker
workspace={wsId}
{fixedScriptPath}
bind:itemKind
bind:scriptPath={script_path}
{initialScriptPath}
canWrite={can_write}
isOperator={!!actingUser?.operator}
promptText="Pick a script or flow to be triggered "
promptClass="text-xs text-primary"
>
{#snippet createButton()}
{#if emptyString(script_path) && is_flow === false}
<div class="flex">
<Button
disabled={!can_write}
btnClasses="ml-4"
variant="accent"
size="xs"
on:click={getTemplateScript}
target="_blank"
{loading}
>Create from template
<Tooltip light>
The conversion requires a <strong>database resource</strong> and at least one
<strong>schema</strong>
to be set.<br />
Please ensure these conditions are met before proceeding.
</Tooltip>
</Button>
</div>
{/if}
{/snippet}
</TriggerRunnablePicker>
</Section>
{/if}
<Section label="Database">
{#snippet badge()}
{#if isEditor}
<TestingBadge />
{/if}
{/snippet}
<p class="text-xs text-primary mb-2">
Pick a database to connect to <Required required={true} />
</p>
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-2">
<ResourcePicker
workspace={wsId}
disabled={!can_write}
bind:value={postgres_resource_path}
resourceType={'postgresql'}
datatableAsPgResource
/>
<CheckPostgresRequirement {postgres_resource_path} {can_write} />
</div>
{#if loadingPostgres}
<div class="flex flex-col items-center justify-center h-full w-full">
<Loader2 size="50" class="animate-spin" />
<p>Loading...</p>
</div>
{:else if postgres_resource_path}
<Label label="Transactions">
{#snippet header()}
<Tooltip>
<p>
Choose the types of database transactions that should trigger a script or flow.
You can select from <strong>Insert</strong>, <strong>Update</strong>,
<strong>Delete</strong>, or any combination of these operations to define when
the trigger should activate.
</p>
</Tooltip>
{/snippet}
<MultiSelect
bind:value={transaction_to_track}
items={safeSelectItems(transactionType)}
onCreateItem={(x) => (transactionType.push(x), transaction_to_track.push(x))}
placeholder="Select transactions"
disabled={!can_write}
/>
</Label>
<Label label="Table Tracking" headerClass="grow min-w-0">
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/postgres_triggers#define-what-to-track"
>
<p>
Select the tables to track. You can choose to track
<strong>all tables in your database</strong>,
<strong>all tables within a specific schema</strong>,
<strong>specific tables in a schema</strong>, or even
<strong>specific columns of a table</strong>. Additionally, you can apply a
<strong>filter</strong> to retrieve only rows that do not match the specified criteria.
</p>
</Tooltip>
{#if !emptyStringTrimmed(errorMessage)}
<p
class="text-red-500 text-xs truncate w-full whitespace-nowrap"
title={errorMessage}
transition:fade={{ duration: 100 }}
>
{errorMessage}
</p>
{/if}
{/snippet}
<Tabs bind:selected={tab}>
<Tab value="basic" label="Basic">
{#snippet extra()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/postgres_triggers#define-what-to-track"
><p
>Choose the <strong>relations</strong> to track without worrying about the
underlying mechanics of creating a
<strong>publication</strong>
or <strong>slot</strong>. This simplified option lets you focus only on the
data you want to monitor.</p
></Tooltip
>
{/snippet}
</Tab>
<Tab value="advanced" label="Advanced">
{#snippet extra()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/postgres_triggers#advanced"
><p
>Select a specific <strong>publication</strong> from your database to track,
and manage it by <strong>creating</strong>,
<strong>updating</strong>, or <strong>deleting</strong>. For
<strong>slots</strong>, you can <strong>create</strong> or
<strong>delete</strong>
them. Both <strong>non-active slots</strong> and the
<strong>currently used slot</strong> by the trigger will be retrieved from your
database for management.</p
></Tooltip
>
{/snippet}
</Tab>
{#snippet content()}
<div class="mt-5 overflow-hidden bg-surface">
<TabContent value="basic">
<RelationPicker {can_write} bind:pg14 bind:relations disabled={!can_write} />
</TabContent>
<TabContent value="advanced">
<div class="flex flex-col gap-6"
><Section
small
label="Replication slot"
tooltip="Choose and manage the slots for your trigger. You can create or delete slots. Both non-active slots and the currently used slot by the trigger (if any) will be retrieved from your database for management."
documentationLink="https://www.windmill.dev/docs/core_concepts/postgres_triggers#managing-postgres-replication-slots"
>
<div class="flex flex-col gap-3">
<ToggleButtonGroup
bind:selected={selectedSlotAction}
on:selected={() => {
replication_slot_name = ''
}}
disabled={!can_write}
>
{#snippet children({ item })}
<ToggleButton value="create" label="Create Slot" {item} />
<ToggleButton value="get" label="Get Slot" {item} />
{/snippet}
</ToggleButtonGroup>
{#if selectedSlotAction === 'create'}
<div class="flex gap-3">
<input
type="text"
bind:value={replication_slot_name}
placeholder={'Choose a slot name'}
disabled={!can_write}
/>
<Button
loading={creatingSlot}
size="xs"
variant="default"
disabled={emptyStringTrimmed(replication_slot_name) || !can_write}
on:click={createSlot}>Create</Button
>
</div>
{:else}
<SlotPicker
{edit}
{postgres_resource_path}
bind:replication_slot_name
disabled={!can_write}
/>
{/if}
</div>
</Section>
<Section
small
label="Publication"
tooltip="Select and manage the publications for tracking data. You can create, update, or delete publications. Only existing publications in your database will be available for selection, giving you full control over what data is tracked."
documentationLink="https://www.windmill.dev/docs/core_concepts/postgres_triggers#managing-postgres-publications"
>
<div class="flex flex-col gap-3">
<ToggleButtonGroup
selected={selectedPublicationAction}
disabled={!can_write}
on:selected={({ detail }) => {
selectedPublicationAction = detail
if (selectedPublicationAction === 'create') {
publication_name = `windmill_publication_${random_adj()}`
relations = [{ schema_name: 'public', table_to_track: [] }]
return
}
publication_name = ''
relations = []
transaction_to_track = []
}}
>
{#snippet children({ item })}
<ToggleButton value="create" label="Create Publication" {item} />
<ToggleButton value="get" label="Get Publication" {item} />
{/snippet}
</ToggleButtonGroup>
{#if selectedPublicationAction === 'create'}
<div class="flex gap-3">
<input
disabled={!can_write}
type="text"
bind:value={publication_name}
placeholder={'Publication Name'}
/>
<Button
loading={creatingPublication}
size="xs"
variant="default"
disabled={emptyStringTrimmed(publication_name) ||
(relations && relations.length === 0) ||
!can_write}
on:click={createPublication}>Create</Button
>
</div>
{:else}
<PublicationPicker
{can_write}
{postgres_resource_path}
bind:transaction_to_track
bind:relations
bind:items={publicationItems}
bind:publication_name
disabled={!can_write}
/>
{/if}
<RelationPicker
bind:pg14
{can_write}
bind:relations
disabled={!can_write}
/>
</div>
</Section></div
>
</TabContent>
</div>
{/snippet}
</Tabs>
</Label>
{/if}
</div>
</Section>
<Section label="Advanced" collapsable>
{#snippet header()}
<TriggerAdvancedBadges {error_handler_path} {retry} />
{/snippet}
<div class="flex flex-col gap-6">
<div class="min-h-96">
<Tabs bind:selected={optionTabSelected}>
<Tab value="error_handler" label="Error Handler" />
<Tab value="retries" label="Retries" />
</Tabs>
<div class="mt-4">
<TriggerRetriesAndErrorHandler
workspace={wsId}
{optionTabSelected}
{itemKind}
{can_write}
bind:errorHandlerSelected
bind:error_handler_path
bind:error_handler_args
bind:retry
/>
</div>
</div>
</div>
</Section>
<div class="pb-8" />
</div>
{/if}
{/snippet}