mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-27 08:02:39 +00:00
* fix(frontend): scope session pipeline trigger editors to the session workspace Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): hoist triggerWorkspace decl above GCP init-time getBaseUrl call Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): scope nested trigger pickers to the session workspace The triggerWorkspace resolver scoped direct trigger CRUD calls to the session's (forked) workspace, but nested pickers still defaulted to the nav `$workspaceStore`: in a fork session, resource lists/creation, variable creation, and path-existence checks ran against the parent workspace while save/delete targeted the fork — misleading options and false path-validation failures. Thread `wsId` into the nested controls of the 9 pipeline-canvas kinds: - `<Path workspaceOverride={wsId}>` (8 editors) — path + folder checks - `<ResourcePicker workspace={wsId}>` (6 config sections; add the resolver to MqttEditorConfigSection, which lacked `wsId`) - SQS `<VariableEditor workspace={wsId}>` — variable creation Also drop the per-site `wsId` rationale comment repeated across ~20 files; the invariant is documented once in triggerWorkspace.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): scope trigger runnable picker to the session workspace The runnable picker (`ScriptPicker`, reached via `TriggerRunnablePicker` and directly in the schedule editor) listed scripts/flows/apps from the nav `$workspaceStore` with no override, so a forked session offered the parent workspace's runnables when attaching a script/flow to a trigger. Add an optional `workspace` prop to `ScriptPicker` (defaults to `$workspaceStore` → no change for existing callers), pass it through `TriggerRunnablePicker`, and wire `wsId` from the 7 trigger editors that use it plus the schedule editor's 3 direct pickers. Completes the nested-picker workspace scoping; the excluded kinds (azure/http/websocket/native) keep their own ScriptPickers unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): scope trigger error-handler, folder default, and runnable actions Follow-up to the trigger-workspace scoping: three subtrees still read the nav `$workspaceStore` in a forked session. - ErrorOrRecoveryHandler (via TriggerRetriesAndErrorHandler in the 7 in-scope editors): add a `workspace` prop (defaults to `$workspaceStore`) and route handler lookup/schema, Slack/Teams settings, test jobs, and run links through it, so the error handler is resolved/tested/saved in the session workspace instead of A while the trigger lives in B. - useFolderDefaultPermissionedAs: accept an optional workspace getter so a `f/...` trigger's default permissioned-as is read from the session workspace, not the nav one (PermissionedAsLine passes `() => wsId`). - ScriptPicker actions: scope the View drawer (`getScriptByPath`) and `FlowPathViewer` to `effectiveWorkspace`, and carry `?workspace=` onto the Edit/View routes when an explicit override is set (the layout consumes the param, same mechanism as editInFork). The param is only appended when a workspace override is passed, so existing callers' links are unchanged. Also consolidate the repeated workspace-scoping comment in PipelineTriggerEditors (the invariant lives in triggerWorkspace.ts). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): scope error-handler/schedule nested consumers to the session workspace Address CI Codex review on #10032 — the error-handler and schedule subtrees still had nested consumers reading the nav workspace: - ErrorOrRecoveryHandler: pass the resolved workspace to its own nested `ScriptPicker` (custom-handler list + View/Edit) and add a `workspace` prop to `ChannelSelector` (Teams channel listing); carry the acting workspace onto the "create from template" link. - ScheduleEditorInner: pass `workspace={wsId}` to the error/recovery/ success `ErrorOrRecoveryHandler` panels, `workspaceId={wsId}` to `WorkerTagPicker`, and the workspace query param onto the dynamic-skip template link. Template-link and picker overrides only diverge from `$workspaceStore` when a session override is set, so non-session callers are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
975 lines
31 KiB
Svelte
975 lines
31 KiB
Svelte
<script lang="ts">
|
|
import { Alert, Button, TabContent } from '$lib/components/common'
|
|
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, userStore, workspaceStore } from '$lib/stores'
|
|
import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace'
|
|
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'
|
|
|
|
interface Props {
|
|
useDrawer?: boolean
|
|
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,
|
|
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 triggerWs = getTriggerWorkspace()
|
|
const wsId = $derived(triggerWs?.() ?? $workspaceStore)
|
|
|
|
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 can_write: boolean = $state(true)
|
|
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
|
|
) {
|
|
let loadingTimeout = setTimeout(() => {
|
|
showLoading = true
|
|
}, 100) // Do not show loading spinner for the first 100ms
|
|
drawerLoading = true
|
|
try {
|
|
drawer?.openDrawer()
|
|
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> {
|
|
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
|
|
can_write = canWrite(path, cfg?.extra_perms, $userStore)
|
|
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(`${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}
|
|
|
|
{#if useDrawer}
|
|
<Drawer size="800px" bind:this={drawer}>
|
|
<DrawerContent
|
|
bannerReserved={draftSync.hasBaseline}
|
|
title={edit
|
|
? can_write
|
|
? `Edit Postgres trigger ${initialPath}`
|
|
: `Postgres trigger ${initialPath}`
|
|
: 'New Postgres trigger'}
|
|
on:close={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>
|
|
</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
|
|
{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={!!$userStore?.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}
|