mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 00:04:10 +00:00
* feat: add Azure Event Grid triggers (EE)
Introduces a new enterprise trigger kind `azure` that supports three
modes via a single unified trigger type:
- basic_push: Azure Event Grid basic — custom topics, system topics
(Storage, Resource Manager, Key Vault, etc.), domains (push only)
- namespace_push: Event Grid Namespace topics (CloudEvents over HTTP push)
- namespace_pull: Event Grid Namespace topics (HTTP pull with lock-token
ack/reject for dead-lettering)
Auth uses a Service Principal resource (tenant_id, client_id,
client_secret, subscription_id). Subscriptions are created in
CloudEvents 1.0 schema so the push webhook handler and the pull listener
share one payload parser.
Backend
- New crate `windmill-trigger-azure` (OSS stubs + EE impl symlinked from
windmill-ee-private)
- Migration `azure_trigger` table with CHECK constraints enforcing
mode/columns coherence
- `TriggerKind::Azure`, `JobTriggerKind::Azure`,
`DeployedObject::AzureTrigger` variants
- Push route `/api/azure/w/{workspace}/*path` handles classic
Event Grid SubscriptionValidation handshake and CloudEvents 1.0
abuse-protection OPTIONS handshake
- Optional inbound JWT validation (audience check only for v1)
- Feature flag `azure_trigger` propagated through windmill-api,
windmill-store (resource helper), and added to ee_core
Frontend
- `triggers/azure/` editor with mode toggle (basic/namespace-push/
namespace-pull) and per-mode config (topic ARM id / namespace +
topic name / subscription / filters / push auth / pull options)
- Registered in icon map, display names, save functions, badge,
wrapper, editor, add-trigger menu
OpenAPI
- `AzureTrigger`, `AzureTriggerData`, `AzureMode`,
`AzureSubscriptionMode`, `AzureDeliveryConfig`, `TestAzureConnection`
schemas; `/azure_triggers/*` endpoints; client regenerated
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: update ee-repo-ref to eaa7c3a9cb37a9ccc93f10a2535d929365acd2d8
This commit updates the EE repository reference after PR #541 was merged in windmill-ee-private.
Previous ee-repo-ref: 9689014e8c12c36c1059fd8fa5758d550b8b8bc9
New ee-repo-ref: eaa7c3a9cb37a9ccc93f10a2535d929365acd2d8
Automated by sync-ee-ref workflow.
* feat(azure-trigger): secret-auth push, ARM discovery, capture isolation, CLI + parity
Frontend:
- Split mode selector into Namespace/Basic + Pull/Push
- ARM resource dropdowns (namespaces, Basic topics, namespace topics)
populated from the service principal; cascade with stale-selection
reset on SP / edition change
- Remove stale authenticate toggle + audience input (server-managed
push_auth_config has replaced them)
- Azure listing page: "Create from template" button; "Also delete Azure
subscription" toggle in the delete modal; simplified trigger label
falling back to path
- AzureCapture.svelte: "Test subscription name" with -wm-capture suffix
- CompareWorkspaces.svelte: wire Azure for fork/compare
- Drop Trigger-deployed/event-loss warning (capture subscription is
isolated with -wm-capture)
Backend:
- Shared-secret push auth (see EE crate for detail)
- JSONB push_auth_config column (renamed from delivery_config), #[serde(skip)]
so clients/CLI/exports never see it
- Drop redundant enabled column; mode supersedes
- Azure capture infra: AzureTriggerConfig + set_azure_trigger_config +
azure_payload route + TriggerKind::Azure arm; PT15M queue TTL on
capture subscriptions so they bound storage after tab close
- Granular ACLs, users offboarding, trash, git-sync deployed-object:
all include azure_trigger
CLI:
- Add azure to TRIGGER_TYPES, pushObj dispatch, getTypeStrFromPath,
trigger commands (get/update/create/list/template), sync delete
switch + regex; e2e test for `trigger new --kind azure`
- system_prompts: SCHEMA_MAPPINGS + schema_names include AzureTrigger;
auto-generated/* regenerated
Skill:
- .claude/skills/adding-a-trigger/ checklist covering every file that
needs editing when wiring a new trigger type (learned from this PR)
ee-repo-ref bumped to b0e490cbf3724b7b64c6a5b010e3bdf24acd873c.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(azure-trigger): ci — ShareModal Kind + regenerated system_prompts
- frontend/src/lib/components/ShareModal.svelte: add 'azure_trigger'
to the Kind type so the listing page's "Permissions" action compiles
(ts2345 — caught by npm_check on CI, missed by fast-check locally).
- system_prompts/auto-generated/: regenerate to drop the stale
delivery_config / AzureDeliveryConfig fields from the Azure schema
(check-freshness on CI).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(azure-trigger): use workspace constant_time_eq crate
Drop hand-rolled constant-time compare in favour of the workspace
constant_time_eq crate (same one used by http_trigger_auth).
ee-repo-ref bumped to 9659382d47286e7f7f66d01b6f5dd8d4ed34848b.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(azure-trigger): pass placeholder + disabled via inputProps
`TextInput`'s `placeholder` and `disabled` go through its `inputProps`
prop — CI's `npm run check` caught the stale top-level passing that
`npm run check:fast` missed. Align with the DefaultEmailConfigSection
pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(azure-trigger): correct LATEST_GIT_SYNC_SCRIPT_PATH version to 28213
The hub deploy of the azure-aware sync-script is version 28213, not
28214. Backend was pinning a non-existent hub script, which broke the
git_sync_e2e suite (every deploy's sync step 404'd).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(azure-trigger): add azure_triggers to token scope selector + skill
- windmill-api/src/token.rs: `build_trigger_scope_domains` was missing
`("azure_triggers", "Azure Event Grid")`, so the CreateToken UI's scope
selector didn't surface azure_triggers:read/write. Backend already had
`ScopeDomain::AzureTriggers` wired (scopes.rs), this just exposes it.
- .claude/skills/adding-a-trigger/SKILL.md: capture both scope-related
files under the hardcoded-arrays section so future triggers don't miss
the UI surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(adding-a-trigger-skill): clarify token.rs scope effect
Not a regression — nothing was working before. Skipping TRIGGER_DOMAINS
just means the scope works via API/CLI but has no UI checkbox.
* docs(adding-a-trigger-skill): trim token.rs bullet
* fix(azure-trigger): regen openapi-deref + swap textarea for TextInput
- Run build_openapi.sh to regenerate openapi-deref.{yaml,json} with the
12 azure_triggers paths + schemas. These files are served by the
runtime (include_str! in windmill-api/src/lib.rs) to external SDK
consumers; without this regen the new endpoints wouldn't be advertised.
- Replace the raw <textarea> for event type filters with the
design-system TextInput in textarea mode (frontend/CLAUDE.md bans raw
HTML elements).
Addresses cubic + claude PR review items.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
1358 lines
44 KiB
Svelte
1358 lines
44 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
AlertTriangle,
|
|
ArrowDown,
|
|
ArrowDownRight,
|
|
ArrowRight,
|
|
ArrowUp,
|
|
ArrowUpRight,
|
|
Building,
|
|
CircleCheck,
|
|
CircleX,
|
|
DiffIcon,
|
|
Eye,
|
|
FileJson,
|
|
FlaskConical,
|
|
GitFork,
|
|
Loader2,
|
|
Trash2,
|
|
UserPlus
|
|
} from 'lucide-svelte'
|
|
import type { CiTestResult } from '$lib/gen'
|
|
import { Alert, Badge } from './common'
|
|
import {
|
|
AppService,
|
|
EmailTriggerService,
|
|
FlowService,
|
|
FolderService,
|
|
AzureTriggerService,
|
|
GcpTriggerService,
|
|
HttpTriggerService,
|
|
KafkaTriggerService,
|
|
MqttTriggerService,
|
|
NatsTriggerService,
|
|
PostgresTriggerService,
|
|
ScheduleService,
|
|
ScriptService,
|
|
SqsTriggerService,
|
|
UserService,
|
|
WebsocketTriggerService,
|
|
WorkspaceService,
|
|
type WorkspaceComparison,
|
|
type WorkspaceItemDiff
|
|
} from '$lib/gen'
|
|
import Button from './common/button/Button.svelte'
|
|
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
|
|
import DiffDrawer from './DiffDrawer.svelte'
|
|
import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte'
|
|
import ScheduleEditor from './triggers/schedules/ScheduleEditor.svelte'
|
|
import RouteEditor from './triggers/http/RouteEditor.svelte'
|
|
import WebsocketTriggerEditor from './triggers/websocket/WebsocketTriggerEditor.svelte'
|
|
import KafkaTriggerEditor from './triggers/kafka/KafkaTriggerEditor.svelte'
|
|
import PostgresTriggerEditor from './triggers/postgres/PostgresTriggerEditor.svelte'
|
|
import NatsTriggerEditor from './triggers/nats/NatsTriggerEditor.svelte'
|
|
import MqttTriggerEditor from './triggers/mqtt/MqttTriggerEditor.svelte'
|
|
import SqsTriggerEditor from './triggers/sqs/SqsTriggerEditor.svelte'
|
|
import GcpTriggerEditor from './triggers/gcp/GcpTriggerEditor.svelte'
|
|
import AzureTriggerEditor from './triggers/azure/AzureTriggerEditor.svelte'
|
|
import EmailTriggerEditor from './triggers/email/EmailTriggerEditor.svelte'
|
|
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
|
|
|
import type { Kind } from '$lib/utils_deployable'
|
|
import {
|
|
deployItem,
|
|
deleteItemInWorkspace,
|
|
getItemValue,
|
|
getOnBehalfOf,
|
|
type DeployResult
|
|
} from '$lib/utils_workspace_deploy'
|
|
import Tooltip from './Tooltip.svelte'
|
|
import OnBehalfOfSelector, {
|
|
needsOnBehalfOfSelection,
|
|
type OnBehalfOfChoice,
|
|
type OnBehalfOfDetails
|
|
} from './OnBehalfOfSelector.svelte'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { deepEqual } from 'fast-equals'
|
|
import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte'
|
|
import DeploymentRequestPanel from './deploymentRequest/DeploymentRequestPanel.svelte'
|
|
import { userStore } from '$lib/stores'
|
|
import type { TriggerKind } from './triggers'
|
|
import { triggerDisplayNamesMap, triggerKindToTriggerType } from './triggers/utils'
|
|
import { getEmailAddress, getEmailDomain } from './triggers/email/utils'
|
|
import { base } from '$lib/base'
|
|
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
|
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
|
import DatatableSchemaDiff from './DatatableSchemaDiff.svelte'
|
|
|
|
interface Props {
|
|
currentWorkspaceId: string
|
|
parentWorkspaceId: string
|
|
comparison: WorkspaceComparison | undefined
|
|
}
|
|
|
|
let { currentWorkspaceId, parentWorkspaceId, comparison }: Props = $props()
|
|
|
|
let currentWorkspaceInfo = $derived($userWorkspaces.find((w) => w.id == currentWorkspaceId))
|
|
let parentWorkspaceInfo = $derived($userWorkspaces.find((w) => w.id == parentWorkspaceId))
|
|
|
|
let mergeIntoParent = $state(true)
|
|
let deploying = $state(false)
|
|
let hasAutoSelected = $state(false)
|
|
let canDeployToParent = $state(true)
|
|
let canPreserveInParent = $state(false)
|
|
let canPreserveInCurrent = $state(false)
|
|
let canPreserveOnBehalfOf = $derived(mergeIntoParent ? canPreserveInParent : canPreserveInCurrent)
|
|
|
|
let selectableDiffs = $derived(
|
|
comparison?.diffs.filter((diff) => {
|
|
if (mergeIntoParent) {
|
|
return diff.ahead > 0
|
|
} else {
|
|
return diff.behind > 0
|
|
}
|
|
}) ?? []
|
|
)
|
|
|
|
let selectedItems = $state<string[]>([])
|
|
|
|
let conflictingDiffs = $derived(
|
|
comparison?.diffs.filter((diff) => diff.ahead > 0 && diff.behind > 0) ?? []
|
|
)
|
|
|
|
let itemsWithBehindChanges = $derived(
|
|
comparison?.diffs.filter((diff) => {
|
|
const status = deploymentStatus[getItemKey(diff)]?.status
|
|
return diff && diff.behind > 0 && diff.ahead == 0 && !(status && status == 'deployed')
|
|
}) ?? []
|
|
)
|
|
|
|
let itemsWithAheadChanges = $derived(
|
|
comparison?.diffs.filter((diff) => {
|
|
const status = deploymentStatus[getItemKey(diff)]?.status
|
|
return diff && diff.ahead > 0 && !(status && status == 'deployed')
|
|
}) ?? []
|
|
)
|
|
|
|
let hasBehindChanges = $derived(mergeIntoParent && itemsWithBehindChanges.length > 0)
|
|
let hasAheadChanges = $derived(mergeIntoParent && itemsWithAheadChanges.length > 0)
|
|
|
|
// Summary cache: stores summaries from both workspaces
|
|
type SummaryCache = Record<string, { current?: string; parent?: string; loading?: boolean }>
|
|
let summaryCache = $state<SummaryCache>({})
|
|
|
|
// On-behalf-of tracking for flows and scripts
|
|
// Source workspace on_behalf_of emails (keyed by workspace/kind:path)
|
|
let onBehalfOfInfo = $state<Record<string, string | undefined>>({})
|
|
let onBehalfOfChoice = $state<Record<string, OnBehalfOfChoice>>({})
|
|
let customOnBehalfOf = $state<Record<string, OnBehalfOfDetails>>({})
|
|
let deployTargetWorkspace = $derived(mergeIntoParent ? parentWorkspaceId : currentWorkspaceId)
|
|
|
|
function getItemKey(diff: WorkspaceItemDiff): string {
|
|
return `${diff.kind}:${diff.path}`
|
|
}
|
|
|
|
async function fetchSummary(
|
|
kind: string,
|
|
path: string,
|
|
workspace: string
|
|
): Promise<string | undefined> {
|
|
try {
|
|
if (kind === 'script') {
|
|
const script = await ScriptService.getScriptByPath({ workspace, path })
|
|
return script.summary
|
|
} else if (kind === 'flow') {
|
|
const flow = await FlowService.getFlowByPath({ workspace, path })
|
|
return flow.summary
|
|
} else if (kind === 'app' || kind === 'raw_app') {
|
|
const app = await AppService.getAppByPath({ workspace, path })
|
|
return app.summary
|
|
} else if (kind === 'folder') {
|
|
const folder = await FolderService.getFolder({ workspace, name: path.replace(/^f\//, '') })
|
|
return folder.summary
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to fetch summary for ${kind}:${path}`, error)
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
async function fetchSummaries(diffs: WorkspaceItemDiff[]) {
|
|
// Only fetch summaries for scripts, flows, and apps
|
|
const itemsToFetch = diffs.filter((diff) =>
|
|
['script', 'flow', 'app', 'raw_app', 'folder'].includes(diff.kind)
|
|
)
|
|
|
|
for (const diff of itemsToFetch) {
|
|
const key = getItemKey(diff)
|
|
|
|
// Skip if already cached or loading
|
|
if (summaryCache[key]) continue
|
|
|
|
// Mark as loading
|
|
summaryCache[key] = { loading: true }
|
|
|
|
// Fetch from both workspaces in parallel
|
|
const [currentSummary, parentSummary] = await Promise.all([
|
|
fetchSummary(diff.kind, diff.path, currentWorkspaceId),
|
|
fetchSummary(diff.kind, diff.path, parentWorkspaceId)
|
|
])
|
|
|
|
summaryCache[key] = {
|
|
current: currentSummary,
|
|
parent: parentSummary,
|
|
loading: false
|
|
}
|
|
}
|
|
}
|
|
|
|
async function fetchOnBehalfOfInfo(diffs: WorkspaceItemDiff[]) {
|
|
const flowsAndScripts = diffs.filter((d) =>
|
|
['flow', 'script', 'app', 'raw_app'].includes(d.kind)
|
|
)
|
|
for (const diff of flowsAndScripts) {
|
|
for (const workspace of [currentWorkspaceId, parentWorkspaceId]) {
|
|
const workspacedKey = getWorkspacedKey(workspace, getItemKey(diff))
|
|
if (onBehalfOfInfo[workspacedKey] !== undefined) continue
|
|
|
|
try {
|
|
onBehalfOfInfo[workspacedKey] = await getOnBehalfOf(
|
|
diff.kind as Kind,
|
|
diff.path,
|
|
workspace
|
|
)
|
|
} catch {
|
|
onBehalfOfInfo[workspacedKey] = undefined
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get source workspace on_behalf_of value for an item (email for runnables, permissioned_as for triggers)
|
|
function getSourceOnBehalfOf(itemKey: string): string | undefined {
|
|
const sourceWorkspace = mergeIntoParent ? currentWorkspaceId : parentWorkspaceId
|
|
return onBehalfOfInfo[getWorkspacedKey(sourceWorkspace, itemKey)]
|
|
}
|
|
|
|
// Get target workspace on_behalf_of value for an item (existing item in destination)
|
|
function getTargetOnBehalfOf(itemKey: string): string | undefined {
|
|
const targetWorkspace = mergeIntoParent ? parentWorkspaceId : currentWorkspaceId
|
|
return onBehalfOfInfo[getWorkspacedKey(targetWorkspace, itemKey)]
|
|
}
|
|
|
|
// Check if an item needs on_behalf_of selection
|
|
function itemNeedsOnBehalfOfSelection(itemKey: string, kind: string): boolean {
|
|
return needsOnBehalfOfSelection(kind, getSourceOnBehalfOf(itemKey))
|
|
}
|
|
|
|
// Check if all required on_behalf_of selections are made
|
|
let hasUnselectedOnBehalfOf = $derived(
|
|
selectedItems.some((itemKey) => {
|
|
const diff = selectableDiffs.find((d) => getItemKey(d) === itemKey)
|
|
if (!diff) return false
|
|
return (
|
|
itemNeedsOnBehalfOfSelection(itemKey, diff.kind) && onBehalfOfChoice[itemKey] === undefined
|
|
)
|
|
})
|
|
)
|
|
|
|
/**
|
|
* Get the on_behalf_of value for deployment based on user's choice.
|
|
* Returns an email for flows/scripts/apps, or permissioned_as (u/username, g/group) for triggers/schedules.
|
|
*/
|
|
function getOnBehalfOfForDeploy(itemKey: string, kind: Kind): string | undefined {
|
|
const choice = onBehalfOfChoice[itemKey]
|
|
if (choice === 'target') return getTargetOnBehalfOf(itemKey)
|
|
if (choice === 'custom') {
|
|
const details = customOnBehalfOf[itemKey]
|
|
return kind === 'trigger' ? details?.permissionedAs : details?.email
|
|
}
|
|
// 'me' or undefined = don't pass, backend will use deploying user's identity
|
|
return undefined
|
|
}
|
|
|
|
let diffDrawer: DiffDrawer | undefined = $state(undefined)
|
|
let isFlow = $state(true)
|
|
|
|
async function showDiff(kind: Kind, path: string) {
|
|
const workspaceTo = mergeIntoParent ? parentWorkspaceId : currentWorkspaceId
|
|
const workspaceFrom = mergeIntoParent ? currentWorkspaceId : parentWorkspaceId
|
|
if (diffDrawer) {
|
|
isFlow = kind == 'flow'
|
|
diffDrawer?.openDrawer()
|
|
let values = await Promise.all([
|
|
getItemValue(kind, path, workspaceTo),
|
|
getItemValue(kind, path, workspaceFrom)
|
|
])
|
|
diffDrawer?.setDiff({
|
|
mode: 'simple',
|
|
original: values?.[0] as any,
|
|
current: values?.[1] as any,
|
|
title: `${workspaceFrom} <> ${workspaceTo}`
|
|
})
|
|
}
|
|
}
|
|
|
|
// All *diff* items selected. Trigger items are opt-in and don't count
|
|
// toward "all selected" — see item merge below in deployableItems.
|
|
let allSelected = $derived(
|
|
selectableDiffs.length > 0 &&
|
|
selectableDiffs.every((d) => selectedItems.includes(getItemKey(d)))
|
|
)
|
|
|
|
async function selectAll() {
|
|
selectedItems = selectableDiffs
|
|
.map((d) => getItemKey(d))
|
|
.filter((k) => !(deploymentStatus[k]?.status == 'deployed'))
|
|
}
|
|
|
|
function deselectAll() {
|
|
selectedItems = []
|
|
}
|
|
|
|
async function selectAllNonConflicts() {
|
|
selectedItems = selectableDiffs
|
|
.filter((d) => !(d.ahead > 0 && d.behind > 0))
|
|
.map((d) => getItemKey(d))
|
|
.filter((k) => !(deploymentStatus[k]?.status == 'deployed'))
|
|
}
|
|
|
|
const deploymentStatus: Record<
|
|
string,
|
|
{ status: 'loading' | 'deployed' | 'failed'; error?: string }
|
|
> = $state({})
|
|
|
|
function getWorkspacedKey(workspace: string, key: string): string {
|
|
return `${workspace}/${key}`
|
|
}
|
|
|
|
async function deploy(
|
|
kind: Kind,
|
|
path: string,
|
|
workspaceToDeployTo: string,
|
|
workspaceFrom: string,
|
|
statusKey: string,
|
|
trigger?: ForkTrigger
|
|
) {
|
|
deploymentStatus[statusKey] = { status: 'loading' }
|
|
|
|
// Check if the item was deleted in the source workspace.
|
|
// If so, archive/delete it in the target workspace instead of copying.
|
|
const diff = comparison?.diffs.find((d) => getItemKey(d) === statusKey)
|
|
const itemDeletedInSource = diff
|
|
? mergeIntoParent
|
|
? diff.exists_in_fork === false
|
|
: diff.exists_in_source === false
|
|
: false
|
|
|
|
let result: DeployResult
|
|
if (itemDeletedInSource) {
|
|
result = await deleteItemInWorkspace(kind, path, workspaceToDeployTo)
|
|
} else if (trigger) {
|
|
result = await deployItem({
|
|
kind: 'trigger',
|
|
path,
|
|
workspaceFrom,
|
|
workspaceTo: workspaceToDeployTo,
|
|
additionalInformation: { triggers: { kind: trigger.triggerKind } },
|
|
onBehalfOf: getOnBehalfOfForDeploy(statusKey, 'trigger')
|
|
})
|
|
} else {
|
|
result = await deployItem({
|
|
kind,
|
|
path,
|
|
workspaceFrom,
|
|
workspaceTo: workspaceToDeployTo,
|
|
onBehalfOf: getOnBehalfOfForDeploy(statusKey, kind)
|
|
})
|
|
}
|
|
|
|
if (result.success) {
|
|
deploymentStatus[statusKey] = { status: 'deployed' }
|
|
} else {
|
|
deploymentStatus[statusKey] = { status: 'failed', error: result.error }
|
|
sendUserToast(`Failed to deploy ${statusKey}: ${result.error}`)
|
|
}
|
|
}
|
|
|
|
let deploymentErrorMessage = $state('')
|
|
let allowBehindChangesOverride = $state(false)
|
|
|
|
async function isComparisonUpToDate(): Promise<boolean> {
|
|
if (!comparison) {
|
|
return false
|
|
}
|
|
|
|
try {
|
|
const result = await WorkspaceService.compareWorkspaces({
|
|
workspace: parentWorkspaceId,
|
|
targetWorkspaceId: currentWorkspaceId
|
|
})
|
|
|
|
const nonDeployedChanges = comparison.diffs.filter(
|
|
(e) => !(deploymentStatus[getItemKey(e)]?.status == 'deployed')
|
|
)
|
|
|
|
if (!deepEqual(nonDeployedChanges, result.diffs)) {
|
|
deploymentErrorMessage = `New changes detected. Please reload the page and review the new changes before ${mergeIntoParent ? 'deploying' : 'updating'}`
|
|
return false
|
|
}
|
|
} catch (e) {
|
|
deploymentErrorMessage = `Failed to check for new changes before deployment: ${e}`
|
|
console.error('Failed to compare workspaces:', e)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
async function deployChanges() {
|
|
deploying = true
|
|
if (!(await isComparisonUpToDate())) {
|
|
deploying = false
|
|
return
|
|
}
|
|
|
|
const parent = parentWorkspaceId
|
|
const current = currentWorkspaceId
|
|
const sortedItems = [...selectedItems].sort((a, b) => {
|
|
const aIsFolder = a.startsWith('folder:')
|
|
const bIsFolder = b.startsWith('folder:')
|
|
if (aIsFolder && !bIsFolder) return -1
|
|
if (!aIsFolder && bIsFolder) return 1
|
|
return 0
|
|
})
|
|
let anyFailed = false
|
|
for (const itemKey of sortedItems) {
|
|
const deployable = deployableItems.find((d) => d.key === itemKey)
|
|
|
|
if (!deployable) {
|
|
sendUserToast(`Undeployable item: ${itemKey}`, true)
|
|
continue
|
|
}
|
|
|
|
const to = mergeIntoParent ? parent : current
|
|
const from = mergeIntoParent ? current : parent
|
|
await deploy(deployable.kind, deployable.path, to, from, itemKey, deployable.trigger)
|
|
if (deploymentStatus[itemKey]?.status === 'failed') {
|
|
anyFailed = true
|
|
}
|
|
}
|
|
deploying = false
|
|
deselectAll()
|
|
|
|
// If every selected item deployed cleanly and the direction was
|
|
// merge-into-parent, close any open deployment request for this fork.
|
|
if (!anyFailed && mergeIntoParent) {
|
|
try {
|
|
const open = await WorkspaceService.getOpenDeploymentRequest({
|
|
workspace: currentWorkspaceId
|
|
})
|
|
if (open) {
|
|
await WorkspaceService.closeDeploymentRequestMerged({
|
|
workspace: currentWorkspaceId,
|
|
id: open.id
|
|
})
|
|
deploymentRequestPanel?.refresh()
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to close open deployment request after merge', e)
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleKey(key: string) {
|
|
if (selectedItems.includes(key)) {
|
|
selectedItems = selectedItems.filter((i) => i !== key)
|
|
} else {
|
|
selectedItems.push(key)
|
|
}
|
|
}
|
|
|
|
function selectDefault() {
|
|
if (mergeIntoParent) {
|
|
selectAll()
|
|
} else {
|
|
selectAllNonConflicts()
|
|
}
|
|
}
|
|
|
|
function toggleDeploymentDirection(v: string) {
|
|
deselectAll()
|
|
mergeIntoParent = v == 'deploy_to'
|
|
selectDefault()
|
|
}
|
|
|
|
// Fetch user permissions for both workspaces
|
|
$effect(() => {
|
|
;[currentWorkspaceId, parentWorkspaceId]
|
|
async function fetchPermissions() {
|
|
try {
|
|
const parentUser = await UserService.whoami({ workspace: parentWorkspaceId })
|
|
canPreserveInParent =
|
|
parentUser.is_admin || parentUser.groups?.includes('wm_deployers') || false
|
|
} catch {
|
|
canPreserveInParent = false
|
|
}
|
|
try {
|
|
const currentUser = await UserService.whoami({ workspace: currentWorkspaceId })
|
|
canPreserveInCurrent =
|
|
currentUser.is_admin || currentUser.groups?.includes('wm_deployers') || false
|
|
} catch {
|
|
canPreserveInCurrent = false
|
|
}
|
|
}
|
|
fetchPermissions()
|
|
})
|
|
|
|
// Fetch summaries and on_behalf_of_email when comparison data loads
|
|
$effect(() => {
|
|
if (comparison?.diffs) {
|
|
fetchSummaries(comparison.diffs)
|
|
fetchOnBehalfOfInfo(comparison.diffs)
|
|
}
|
|
})
|
|
|
|
// Auto-select items on initial load
|
|
$effect(() => {
|
|
if (comparison?.diffs && !hasAutoSelected && selectableDiffs.length > 0) {
|
|
selectDefault()
|
|
hasAutoSelected = true
|
|
}
|
|
})
|
|
|
|
// Reset override when selection or direction changes
|
|
$effect(() => {
|
|
;[selectedItems, mergeIntoParent]
|
|
allowBehindChangesOverride = false
|
|
})
|
|
|
|
function getTriggerKey(trigger: ForkTrigger): string {
|
|
return `trigger:${trigger.triggerKind}:${trigger.path}`
|
|
}
|
|
|
|
// Transform diffs + fork triggers to deployable item format for the
|
|
// shared layout. Triggers render as inline rows alongside diff items;
|
|
// they carry no ahead/behind info and are selectable à la carte.
|
|
let deployableItems = $derived.by(() => {
|
|
const diffItems = (comparison?.diffs ?? [])
|
|
.filter((diff) => {
|
|
const key = getItemKey(diff)
|
|
const isSelectable = selectableDiffs.includes(diff)
|
|
const isDeployedAndIrrelevant =
|
|
deploymentStatus[key]?.status === 'deployed' && !isSelectable
|
|
return !isDeployedAndIrrelevant
|
|
})
|
|
.map((diff) => ({
|
|
key: getItemKey(diff),
|
|
path: diff.path,
|
|
kind: diff.kind as Kind,
|
|
diff,
|
|
trigger: undefined as ForkTrigger | undefined
|
|
}))
|
|
const triggerItems = forkTriggers
|
|
.filter((t) => {
|
|
const key = getTriggerKey(t)
|
|
return deploymentStatus[key]?.status !== 'deployed'
|
|
})
|
|
.map((trigger) => ({
|
|
key: getTriggerKey(trigger),
|
|
path: trigger.path,
|
|
kind: 'trigger' as Kind,
|
|
diff: undefined as WorkspaceItemDiff | undefined,
|
|
trigger
|
|
}))
|
|
return [...diffItems, ...triggerItems]
|
|
})
|
|
|
|
// --- Fork Triggers ---
|
|
|
|
type ForkTrigger = {
|
|
path: string
|
|
triggerKind: TriggerKind
|
|
scriptPath: string
|
|
isFlow: boolean
|
|
enabled?: boolean
|
|
extraLabel?: string
|
|
}
|
|
|
|
let ciTestResults = $state<Record<string, CiTestResult[]>>({})
|
|
|
|
async function fetchCiTests() {
|
|
if (!comparison?.diffs || !currentWorkspaceId) return
|
|
const items = comparison.diffs
|
|
.filter((d) => d.kind === 'script' || d.kind === 'flow' || d.kind === 'resource')
|
|
.map((d) => ({ path: d.path, kind: d.kind as 'script' | 'flow' | 'resource' }))
|
|
if (items.length === 0) return
|
|
try {
|
|
ciTestResults = await ScriptService.getCiTestResultsBatch({
|
|
workspace: currentWorkspaceId,
|
|
requestBody: { items }
|
|
})
|
|
} catch (e) {
|
|
console.error('Failed to fetch CI test results:', e)
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (comparison && comparison.diffs.length > 0) {
|
|
fetchCiTests()
|
|
}
|
|
})
|
|
|
|
function getCiTestStatus(diff: WorkspaceItemDiff): 'pass' | 'fail' | 'running' | null {
|
|
const key = `${diff.kind}:${diff.path}`
|
|
const results = ciTestResults[key]
|
|
if (!results || results.length === 0) return null
|
|
if (results.some((r) => r.status === 'failure' || r.status === 'canceled')) return 'fail'
|
|
if (results.some((r) => r.status === 'running' || (r.job_id && !r.status))) return 'running'
|
|
if (results.every((r) => r.status === 'success')) return 'pass'
|
|
return null
|
|
}
|
|
|
|
// Deduplicated list of all CI tests across all items
|
|
let allCiTests = $derived.by(() => {
|
|
const seen = new Map<string, CiTestResult>()
|
|
for (const results of Object.values(ciTestResults)) {
|
|
for (const r of results) {
|
|
const existing = seen.get(r.test_script_path)
|
|
if (
|
|
!existing ||
|
|
(r.started_at && (!existing.started_at || r.started_at > existing.started_at))
|
|
) {
|
|
seen.set(r.test_script_path, r)
|
|
}
|
|
}
|
|
}
|
|
return [...seen.values()]
|
|
})
|
|
|
|
let forkTriggers = $state<ForkTrigger[]>([])
|
|
let triggerToDelete = $state<ForkTrigger | undefined>(undefined)
|
|
let deploymentRequestPanel: DeploymentRequestPanel | undefined = $state(undefined)
|
|
let hasOpenDeploymentRequest = $state(false)
|
|
|
|
// Trigger detail drawer refs — one per trigger kind. Each is lazy-mounted
|
|
// on first openEdit() call, so having them all sit here is cheap.
|
|
let scheduleEditor: ScheduleEditor | undefined = $state()
|
|
let routeEditor: RouteEditor | undefined = $state()
|
|
let websocketEditor: WebsocketTriggerEditor | undefined = $state()
|
|
let kafkaEditor: KafkaTriggerEditor | undefined = $state()
|
|
let postgresEditor: PostgresTriggerEditor | undefined = $state()
|
|
let natsEditor: NatsTriggerEditor | undefined = $state()
|
|
let mqttEditor: MqttTriggerEditor | undefined = $state()
|
|
let sqsEditor: SqsTriggerEditor | undefined = $state()
|
|
let gcpEditor: GcpTriggerEditor | undefined = $state()
|
|
let azureEditor: AzureTriggerEditor | undefined = $state()
|
|
let emailEditor: EmailTriggerEditor | undefined = $state()
|
|
|
|
function openTriggerDetails(trigger: ForkTrigger) {
|
|
const isFlow = trigger.isFlow
|
|
switch (trigger.triggerKind) {
|
|
case 'schedules':
|
|
scheduleEditor?.openEdit(trigger.path, isFlow)
|
|
break
|
|
case 'routes':
|
|
routeEditor?.openEdit(trigger.path, isFlow)
|
|
break
|
|
case 'websockets':
|
|
websocketEditor?.openEdit(trigger.path, isFlow)
|
|
break
|
|
case 'kafka':
|
|
kafkaEditor?.openEdit(trigger.path, isFlow)
|
|
break
|
|
case 'postgres':
|
|
postgresEditor?.openEdit(trigger.path, isFlow)
|
|
break
|
|
case 'nats':
|
|
natsEditor?.openEdit(trigger.path, isFlow)
|
|
break
|
|
case 'mqtt':
|
|
mqttEditor?.openEdit(trigger.path, isFlow)
|
|
break
|
|
case 'sqs':
|
|
sqsEditor?.openEdit(trigger.path, isFlow)
|
|
break
|
|
case 'gcp':
|
|
gcpEditor?.openEdit(trigger.path, isFlow)
|
|
break
|
|
case 'azure':
|
|
azureEditor?.openEdit(trigger.path, isFlow)
|
|
break
|
|
case 'emails':
|
|
emailEditor?.openEdit(trigger.path, isFlow)
|
|
break
|
|
}
|
|
}
|
|
|
|
/** Deployable trigger kinds and their list+delete services */
|
|
const triggerServices = {
|
|
schedules: {
|
|
list: (ws: string) => ScheduleService.listSchedules({ workspace: ws }),
|
|
delete: (ws: string, path: string) => ScheduleService.deleteSchedule({ workspace: ws, path }),
|
|
normalize: (item: any): ForkTrigger => ({
|
|
path: item.path,
|
|
triggerKind: 'schedules',
|
|
scriptPath: item.script_path,
|
|
isFlow: item.is_flow,
|
|
enabled: item.enabled,
|
|
extraLabel: item.schedule
|
|
})
|
|
},
|
|
routes: {
|
|
list: (ws: string) => HttpTriggerService.listHttpTriggers({ workspace: ws }),
|
|
delete: (ws: string, path: string) =>
|
|
HttpTriggerService.deleteHttpTrigger({ workspace: ws, path }),
|
|
normalize: (item: any): ForkTrigger => ({
|
|
path: item.path,
|
|
triggerKind: 'routes',
|
|
scriptPath: item.script_path,
|
|
isFlow: item.is_flow,
|
|
enabled: item.mode === 'enabled',
|
|
extraLabel: `${(item.http_method ?? 'get').toUpperCase()} ${item.route_path ?? ''}`
|
|
})
|
|
},
|
|
websockets: {
|
|
list: (ws: string) => WebsocketTriggerService.listWebsocketTriggers({ workspace: ws }),
|
|
delete: (ws: string, path: string) =>
|
|
WebsocketTriggerService.deleteWebsocketTrigger({ workspace: ws, path }),
|
|
normalize: (item: any): ForkTrigger => ({
|
|
path: item.path,
|
|
triggerKind: 'websockets',
|
|
scriptPath: item.script_path,
|
|
isFlow: item.is_flow,
|
|
enabled: item.mode === 'enabled',
|
|
extraLabel: item.url
|
|
})
|
|
},
|
|
kafka: {
|
|
list: (ws: string) => KafkaTriggerService.listKafkaTriggers({ workspace: ws }),
|
|
delete: (ws: string, path: string) =>
|
|
KafkaTriggerService.deleteKafkaTrigger({ workspace: ws, path }),
|
|
normalize: (item: any): ForkTrigger => ({
|
|
path: item.path,
|
|
triggerKind: 'kafka',
|
|
scriptPath: item.script_path,
|
|
isFlow: item.is_flow,
|
|
enabled: item.mode === 'enabled',
|
|
extraLabel: item.topics?.join(', ')
|
|
})
|
|
},
|
|
postgres: {
|
|
list: (ws: string) => PostgresTriggerService.listPostgresTriggers({ workspace: ws }),
|
|
delete: (ws: string, path: string) =>
|
|
PostgresTriggerService.deletePostgresTrigger({ workspace: ws, path }),
|
|
normalize: (item: any): ForkTrigger => ({
|
|
path: item.path,
|
|
triggerKind: 'postgres',
|
|
scriptPath: item.script_path,
|
|
isFlow: item.is_flow,
|
|
enabled: item.mode === 'enabled'
|
|
})
|
|
},
|
|
nats: {
|
|
list: (ws: string) => NatsTriggerService.listNatsTriggers({ workspace: ws }),
|
|
delete: (ws: string, path: string) =>
|
|
NatsTriggerService.deleteNatsTrigger({ workspace: ws, path }),
|
|
normalize: (item: any): ForkTrigger => ({
|
|
path: item.path,
|
|
triggerKind: 'nats',
|
|
scriptPath: item.script_path,
|
|
isFlow: item.is_flow,
|
|
enabled: item.mode === 'enabled',
|
|
extraLabel: item.subjects?.join(', ')
|
|
})
|
|
},
|
|
mqtt: {
|
|
list: (ws: string) => MqttTriggerService.listMqttTriggers({ workspace: ws }),
|
|
delete: (ws: string, path: string) =>
|
|
MqttTriggerService.deleteMqttTrigger({ workspace: ws, path }),
|
|
normalize: (item: any): ForkTrigger => ({
|
|
path: item.path,
|
|
triggerKind: 'mqtt',
|
|
scriptPath: item.script_path,
|
|
isFlow: item.is_flow,
|
|
enabled: item.mode === 'enabled'
|
|
})
|
|
},
|
|
sqs: {
|
|
list: (ws: string) => SqsTriggerService.listSqsTriggers({ workspace: ws }),
|
|
delete: (ws: string, path: string) =>
|
|
SqsTriggerService.deleteSqsTrigger({ workspace: ws, path }),
|
|
normalize: (item: any): ForkTrigger => ({
|
|
path: item.path,
|
|
triggerKind: 'sqs',
|
|
scriptPath: item.script_path,
|
|
isFlow: item.is_flow,
|
|
enabled: item.mode === 'enabled',
|
|
extraLabel: item.queue_url
|
|
})
|
|
},
|
|
gcp: {
|
|
list: (ws: string) => GcpTriggerService.listGcpTriggers({ workspace: ws }),
|
|
delete: (ws: string, path: string) =>
|
|
GcpTriggerService.deleteGcpTrigger({ workspace: ws, path }),
|
|
normalize: (item: any): ForkTrigger => ({
|
|
path: item.path,
|
|
triggerKind: 'gcp',
|
|
scriptPath: item.script_path,
|
|
isFlow: item.is_flow,
|
|
enabled: item.mode === 'enabled',
|
|
extraLabel: item.topic_id
|
|
})
|
|
},
|
|
azure: {
|
|
list: (ws: string) => AzureTriggerService.listAzureTriggers({ workspace: ws }),
|
|
delete: (ws: string, path: string) =>
|
|
AzureTriggerService.deleteAzureTrigger({ workspace: ws, path }),
|
|
normalize: (item: any): ForkTrigger => ({
|
|
path: item.path,
|
|
triggerKind: 'azure',
|
|
scriptPath: item.script_path,
|
|
isFlow: item.is_flow,
|
|
enabled: item.mode === 'enabled',
|
|
extraLabel: item.topic_name ?? item.scope_resource_id
|
|
})
|
|
},
|
|
emails: {
|
|
list: (ws: string) => EmailTriggerService.listEmailTriggers({ workspace: ws }),
|
|
delete: (ws: string, path: string) =>
|
|
EmailTriggerService.deleteEmailTrigger({ workspace: ws, path }),
|
|
normalize: (item: any): ForkTrigger => ({
|
|
path: item.path,
|
|
triggerKind: 'emails',
|
|
scriptPath: item.script_path,
|
|
isFlow: item.is_flow,
|
|
enabled: item.mode === 'enabled',
|
|
extraLabel: getEmailAddress(
|
|
item.local_part,
|
|
item.workspaced_local_part,
|
|
currentWorkspaceId,
|
|
emailDomain ?? ''
|
|
)
|
|
})
|
|
}
|
|
} as const
|
|
|
|
let emailDomain = $state<string | undefined>(undefined)
|
|
|
|
async function fetchAllTriggers() {
|
|
try {
|
|
emailDomain = await getEmailDomain()
|
|
const entries = Object.values(triggerServices)
|
|
const results = await Promise.allSettled(
|
|
entries.map(async (svc) => {
|
|
const items = await svc.list(currentWorkspaceId)
|
|
return items.map(svc.normalize)
|
|
})
|
|
)
|
|
forkTriggers = results.flatMap((r) => (r.status === 'fulfilled' ? r.value : []))
|
|
} catch (e) {
|
|
console.error('Failed to fetch fork triggers:', e)
|
|
forkTriggers = []
|
|
}
|
|
}
|
|
|
|
function deleteTrigger(trigger: ForkTrigger) {
|
|
triggerToDelete = trigger
|
|
}
|
|
|
|
async function confirmDeleteTrigger() {
|
|
const trigger = triggerToDelete
|
|
if (!trigger) return
|
|
triggerToDelete = undefined
|
|
const triggerType = triggerKindToTriggerType(trigger.triggerKind)
|
|
const displayName = triggerType ? triggerDisplayNamesMap[triggerType] : trigger.triggerKind
|
|
try {
|
|
const svc = triggerServices[trigger.triggerKind as keyof typeof triggerServices]
|
|
if (!svc) {
|
|
throw new Error(`No service for trigger kind: ${trigger.triggerKind}`)
|
|
}
|
|
await svc.delete(currentWorkspaceId, trigger.path)
|
|
forkTriggers = forkTriggers.filter(
|
|
(t) => !(t.path === trigger.path && t.triggerKind === trigger.triggerKind)
|
|
)
|
|
sendUserToast(`Deleted ${displayName} trigger '${trigger.path}'`)
|
|
} catch (e: any) {
|
|
sendUserToast(`Failed to delete trigger '${trigger.path}': ${e.body || e.message}`, true)
|
|
}
|
|
}
|
|
|
|
function getTriggerDisplayName(triggerKind: TriggerKind): string {
|
|
const triggerType = triggerKindToTriggerType(triggerKind)
|
|
return triggerType ? triggerDisplayNamesMap[triggerType] : triggerKind
|
|
}
|
|
|
|
// Fetch triggers when workspace is available
|
|
$effect(() => {
|
|
if (currentWorkspaceId) {
|
|
fetchAllTriggers()
|
|
}
|
|
})
|
|
</script>
|
|
|
|
{#if $workspaceStore != currentWorkspaceId}
|
|
<Alert title="Targetting different workspace" type="info" class="my-2">
|
|
<span>
|
|
You are currently seeing the deployement page of workspace <b
|
|
>{currentWorkspaceInfo?.name} ({currentWorkspaceInfo?.id})</b
|
|
> which is not your currently selected workspace.
|
|
</span>
|
|
<a href="/">Click here to go home ({$workspaceStore})</a>
|
|
</Alert>
|
|
{/if}
|
|
|
|
{#if comparison}
|
|
{@const selectedConflicts = conflictingDiffs.filter((e) =>
|
|
selectedItems.includes(getItemKey(e))
|
|
).length}
|
|
<div class="flex flex-col gap-4">
|
|
<div class="bg-surface-tertiary p-4 rounded-md border">
|
|
<WorkspaceDeployLayout
|
|
items={deployableItems}
|
|
{selectedItems}
|
|
{deploymentStatus}
|
|
selectablePredicate={(item) =>
|
|
item.trigger != null || selectableDiffs.some((d) => getItemKey(d) === item.key)}
|
|
{allSelected}
|
|
onToggleItem={(item) => toggleKey(item.key)}
|
|
onSelectAll={selectAll}
|
|
onDeselectAll={deselectAll}
|
|
emptyMessage="No comparison data available"
|
|
>
|
|
{#snippet header()}
|
|
<div class="flex items-center justify-between bg-surface-tertiary">
|
|
<div class="flex flex-col gap-2 w-full pb-4 border-b">
|
|
<div class="flex flex-wrap gap-1 items-center">
|
|
<ToggleButtonGroup
|
|
disabled={deploying}
|
|
selected="deploy_to"
|
|
onSelected={toggleDeploymentDirection}
|
|
noWFull
|
|
>
|
|
{#snippet children({ item })}
|
|
<ToggleButton
|
|
value="deploy_to"
|
|
label="Deploy to {parentWorkspaceId}"
|
|
icon={ArrowUp}
|
|
{item}
|
|
/>
|
|
<ToggleButton value="update" label="Update current" icon={ArrowDown} {item} />
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
{#if currentWorkspaceInfo && parentWorkspaceInfo}
|
|
<div class="flex-1 flex gap-1 items-center">
|
|
<Badge
|
|
color="transparent"
|
|
class="ml-5 font-semibold"
|
|
title={mergeIntoParent ? currentWorkspaceInfo.name : parentWorkspaceInfo.name}
|
|
>
|
|
<span class="text-secondary">merge:</span>
|
|
{#if mergeIntoParent}
|
|
<GitFork size={14} />
|
|
<span class="text-emphasis">{currentWorkspaceInfo.id}</span>
|
|
{:else}
|
|
<Building size={14} />
|
|
<span class="text-emphasis">{parentWorkspaceInfo.id}</span>
|
|
{/if}
|
|
</Badge>
|
|
<ArrowRight size={16} />
|
|
<Badge
|
|
color="transparent"
|
|
class="font-semibold"
|
|
title={!mergeIntoParent
|
|
? currentWorkspaceInfo.name
|
|
: parentWorkspaceInfo.name}
|
|
>
|
|
<span class="text-secondary">into:</span>
|
|
{#if !mergeIntoParent}
|
|
<GitFork size={14} />
|
|
<span class="text-emphasis">{currentWorkspaceInfo.id}</span>
|
|
{:else}
|
|
<Building size={14} />
|
|
<span class="text-emphasis">{parentWorkspaceInfo.id}</span>
|
|
{/if}
|
|
</Badge>
|
|
</div>
|
|
{/if}
|
|
<div class="flex items-center gap-2 text-sm">
|
|
<Badge color="transparent">
|
|
{comparison.summary.total_diffs} total items
|
|
</Badge>
|
|
<Badge color="transparent">
|
|
{selectableDiffs.length}
|
|
{mergeIntoParent ? 'deployable' : 'updateable'}
|
|
</Badge>
|
|
{#if conflictingDiffs.length > 0}
|
|
<Badge color="orange">
|
|
<AlertTriangle class="w-3 h-3 inline mr-1" />
|
|
{conflictingDiffs.length} conflicts
|
|
</Badge>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/snippet}
|
|
{#if allCiTests.length > 0}
|
|
<div class="flex flex-col gap-1.5 mt-3 p-3 border bg-surface-secondary rounded text-xs">
|
|
<div class="flex items-center gap-1.5 font-semibold text-secondary">
|
|
<FlaskConical size={14} />
|
|
CI tests
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
{#each allCiTests as test (test.test_script_path)}
|
|
<div class="flex items-center gap-2">
|
|
{#if test.status === 'success'}
|
|
<CircleCheck size={14} class="text-green-600" />
|
|
{:else if test.status === 'failure' || test.status === 'canceled'}
|
|
<CircleX size={14} class="text-red-600" />
|
|
{:else if test.status === 'running'}
|
|
<Loader2 size={14} class="text-yellow-600 animate-spin" />
|
|
{:else}
|
|
<div class="w-3.5 h-3.5 rounded-full border border-tertiary"></div>
|
|
{/if}
|
|
<span class="text-secondary">{test.test_script_path}</span>
|
|
{#if test.job_id}
|
|
<a
|
|
href="{base}/run/{test.job_id}?workspace={currentWorkspaceId}"
|
|
class="text-tertiary hover:text-secondary text-2xs"
|
|
>
|
|
view run
|
|
</a>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#snippet alerts()}
|
|
{#if mergeIntoParent}
|
|
<ParentWorkspaceProtectionAlert
|
|
{parentWorkspaceId}
|
|
onUpdateCanDeploy={(canDeploy) => {
|
|
canDeployToParent = canDeploy
|
|
}}
|
|
/>
|
|
{/if}
|
|
{#if conflictingDiffs.length > 0}
|
|
<Alert title="Conflicting changes detected" type="warning" class="mt-2">
|
|
<span>
|
|
{conflictingDiffs.length} item{conflictingDiffs.length !== 1 ? 's have' : ' has'} conflicting
|
|
changes, it was modified on the original workspace while changes were made on this fork.
|
|
Make sure to resolve these before merging.
|
|
</span>
|
|
</Alert>
|
|
{/if}
|
|
{#if hasBehindChanges && hasAheadChanges && !(mergeIntoParent && !canDeployToParent)}
|
|
<Alert
|
|
title="This fork is behind {parentWorkspaceId} and needs to be up to date before deploying"
|
|
type="warning"
|
|
class="my-2"
|
|
>
|
|
You have items behind '{parentWorkspaceId}'. You need to update and test your changes
|
|
before being able to deploy.
|
|
<span class="font-medium flex flex-row gap-1 text-red-500">
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={allowBehindChangesOverride}
|
|
class="rounded max-w-4"
|
|
/>
|
|
Override: Deploy despite {itemsWithBehindChanges.length} outdated item{itemsWithBehindChanges.length !==
|
|
1
|
|
? 's'
|
|
: ''}
|
|
</span>
|
|
</Alert>
|
|
{/if}
|
|
{#if !comparison.all_ahead_items_visible || !comparison.all_behind_items_visible}
|
|
<Alert
|
|
title="This fork has changes not visible to your user"
|
|
type="warning"
|
|
class="my-2"
|
|
>
|
|
{#if !comparison.all_ahead_items_visible && !comparison.all_behind_items_visible}
|
|
This fork is ahead and behind its parent
|
|
{:else if !comparison.all_behind_items_visible}
|
|
This fork is behind of its parent
|
|
{:else if !comparison.all_ahead_items_visible}
|
|
This fork is ahead of its parent
|
|
{/if}
|
|
and some of the changes are not visible by you. Only a user with access to the whole context
|
|
may deploy or update this fork. You can share the link to this page to someone with proper
|
|
permissions to get it deployed.
|
|
</Alert>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet itemSummary(item)}
|
|
{#if item.trigger}
|
|
{@const t = item.trigger as ForkTrigger}
|
|
<span class="text-emphasis">{getTriggerDisplayName(t.triggerKind)}</span>
|
|
<span class="text-secondary mx-1">→</span>
|
|
<span class="text-secondary">{t.scriptPath}</span>
|
|
{:else}
|
|
{@const diff = item.diff as WorkspaceItemDiff}
|
|
{@const key = item.key}
|
|
{@const isSelectable = selectableDiffs.includes(diff)}
|
|
{@const oldSummary = mergeIntoParent
|
|
? summaryCache[key]?.parent
|
|
: summaryCache[key]?.current}
|
|
{@const newSummary = mergeIntoParent
|
|
? summaryCache[key]?.current
|
|
: summaryCache[key]?.parent}
|
|
{@const existsInBothWorkspaces = !(
|
|
(diff.exists_in_fork && !diff.exists_in_source) ||
|
|
(!diff.exists_in_fork && diff.exists_in_source)
|
|
)}
|
|
{#if oldSummary != newSummary && isSelectable && existsInBothWorkspaces}
|
|
<span class="line-through text-secondary">{oldSummary || diff.path}</span>
|
|
{newSummary || diff.path}
|
|
{:else if !existsInBothWorkspaces}
|
|
{newSummary || oldSummary || diff.path}
|
|
{:else}
|
|
{newSummary || diff.path}
|
|
{/if}
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet itemActions(item)}
|
|
{#if item.trigger}
|
|
{@const t = item.trigger as ForkTrigger}
|
|
{@const key = item.key}
|
|
<Badge color="indigo" size="xs">Fork-only</Badge>
|
|
{#if t.isFlow}
|
|
<Badge color="blue" size="xs">flow</Badge>
|
|
{/if}
|
|
{#if t.extraLabel}
|
|
<span class="text-tertiary text-xs">({t.extraLabel})</span>
|
|
{/if}
|
|
{#if t.enabled != null}
|
|
<Badge color={t.enabled ? 'green' : 'gray'} size="xs">
|
|
{t.enabled ? 'Enabled' : 'Disabled'}
|
|
</Badge>
|
|
{/if}
|
|
{#if !deploymentStatus[key] || deploymentStatus[key].status != 'deployed'}
|
|
<Button
|
|
size="xs"
|
|
variant="subtle"
|
|
startIcon={{ icon: Eye }}
|
|
onclick={() => openTriggerDetails(t)}
|
|
>
|
|
Details
|
|
</Button>
|
|
<Button size="xs" variant="subtle" color="red" onclick={() => deleteTrigger(t)}>
|
|
<Trash2 size={12} />
|
|
</Button>
|
|
{/if}
|
|
{:else}
|
|
{@const diff = item.diff as WorkspaceItemDiff}
|
|
{@const key = item.key}
|
|
{@const targetOnBehalfOf = getTargetOnBehalfOf(key)}
|
|
{@const isConflict = diff.ahead > 0 && diff.behind > 0}
|
|
{@const existsInBothWorkspaces = !(
|
|
(diff.exists_in_fork && !diff.exists_in_source) ||
|
|
(!diff.exists_in_fork && diff.exists_in_source)
|
|
)}
|
|
<!-- On-behalf-of selector -->
|
|
{#if itemNeedsOnBehalfOfSelection(key, diff.kind)}
|
|
<OnBehalfOfSelector
|
|
targetWorkspace={deployTargetWorkspace}
|
|
targetValue={targetOnBehalfOf}
|
|
selected={onBehalfOfChoice[key]}
|
|
onSelect={(choice, details) => {
|
|
onBehalfOfChoice[key] = choice
|
|
if (details) customOnBehalfOf[key] = details
|
|
}}
|
|
kind={diff.kind}
|
|
canPreserve={canPreserveOnBehalfOf}
|
|
customValue={customOnBehalfOf[key]?.permissionedAs}
|
|
/>
|
|
{/if}
|
|
{#if diff.kind === 'raw_app'}
|
|
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
|
|
{/if}
|
|
<!-- Status badges -->
|
|
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead == 0 && diff.behind > 0}
|
|
<Badge
|
|
title="This item was newly created in the parent workspace '{parentWorkspaceId}'"
|
|
color="indigo"
|
|
size="xs">New</Badge
|
|
>
|
|
{/if}
|
|
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead > 0}
|
|
<Badge title="This item was deleted in '{currentWorkspaceId}'" color="red" size="xs"
|
|
>Deleted</Badge
|
|
>
|
|
{/if}
|
|
{#if diff.exists_in_fork && !diff.exists_in_source && diff.behind > 0}
|
|
<Badge
|
|
title="This item was deleted in the parent workspace '{parentWorkspaceId}'"
|
|
color="red"
|
|
size="xs">Deleted</Badge
|
|
>
|
|
{/if}
|
|
{#if diff.exists_in_fork && !diff.exists_in_source && diff.ahead > 0 && diff.behind == 0}
|
|
<Badge
|
|
title="This item was newly created in '{currentWorkspaceId}'"
|
|
color="indigo"
|
|
size="xs">New</Badge
|
|
>
|
|
{/if}
|
|
{@const ciStatus = getCiTestStatus(diff)}
|
|
{#if ciStatus === 'pass'}
|
|
<Badge color="green" size="xs"><CircleCheck size={10} class="mr-0.5" />CI pass</Badge>
|
|
{:else if ciStatus === 'fail'}
|
|
<Badge color="red" size="xs"><CircleX size={10} class="mr-0.5" />CI fail</Badge>
|
|
{:else if ciStatus === 'running'}
|
|
<Badge color="yellow" size="xs"
|
|
><Loader2 size={10} class="mr-0.5 animate-spin" />CI</Badge
|
|
>
|
|
{/if}
|
|
{#if !deploymentStatus[key] || deploymentStatus[key].status != 'deployed'}
|
|
<div class="flex items-center gap-2">
|
|
{#if isConflict || existsInBothWorkspaces}
|
|
{#if diff.ahead > 0}
|
|
<Badge color="green" size="xs">
|
|
<ArrowUpRight class="w-3 h-3 inline" />
|
|
{diff.ahead} ahead
|
|
</Badge>
|
|
{/if}
|
|
{#if diff.behind > 0}
|
|
<Badge color="blue" size="xs">
|
|
<ArrowDownRight class="w-3 h-3 inline" />
|
|
{diff.behind} behind
|
|
</Badge>
|
|
{/if}
|
|
{#if isConflict}
|
|
<Badge color="orange" size="xs">
|
|
<AlertTriangle class="w-3 h-3 inline" />
|
|
Conflict
|
|
</Badge>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
<div class:invisible={!existsInBothWorkspaces}>
|
|
<Button
|
|
size="xs"
|
|
variant="subtle"
|
|
onclick={() => showDiff(diff.kind as Kind, diff.path)}
|
|
>
|
|
<DiffIcon class="w-3 h-3" />
|
|
Show diff
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet footer()}
|
|
<div class="flex items-center justify-between">
|
|
<div></div>
|
|
|
|
<div class="flex flex-col items-end gap-2">
|
|
{#if comparison.all_behind_items_visible && comparison.all_ahead_items_visible}
|
|
<div class="flex items-center gap-2">
|
|
{#if mergeIntoParent && !hasOpenDeploymentRequest && !deploymentRequestPanel?.isDialogOpen()}
|
|
<Button
|
|
variant="default"
|
|
startIcon={{ icon: UserPlus }}
|
|
on:click={() => deploymentRequestPanel?.openRequestDialog()}
|
|
>
|
|
Request deployment
|
|
</Button>
|
|
{/if}
|
|
<Button
|
|
variant="accent"
|
|
disabled={selectedItems.length === 0 ||
|
|
deploying ||
|
|
(hasBehindChanges && !allowBehindChangesOverride) ||
|
|
(mergeIntoParent && !canDeployToParent) ||
|
|
hasUnselectedOnBehalfOf}
|
|
loading={deploying}
|
|
on:click={deployChanges}
|
|
>
|
|
{mergeIntoParent ? 'Deploy' : 'Update'}
|
|
{selectedItems.length} Item{selectedItems.length !== 1 ? 's' : ''}
|
|
{#if selectedConflicts != 0}
|
|
({selectedConflicts} conflicts)
|
|
{/if}
|
|
</Button>
|
|
</div>
|
|
{#if !(mergeIntoParent && !canDeployToParent) && hasUnselectedOnBehalfOf}
|
|
<span class="text-xs text-yellow-600">
|
|
You must set the "on behalf of" user for all items before deploying
|
|
<Tooltip class="text-yellow-600">
|
|
The "run on behalf of" field defines which user's permissions will be applied
|
|
during execution. Make sure this is set to an appropriate user before
|
|
deploying.
|
|
</Tooltip>
|
|
</span>
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if deploymentErrorMessage != ''}
|
|
<Alert
|
|
title="Cannot {mergeIntoParent ? 'deploy these changes' : 'update these items'}"
|
|
type="error"
|
|
class="my-2 max-w-80"
|
|
>
|
|
<span>
|
|
{deploymentErrorMessage}
|
|
</span>
|
|
</Alert>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/snippet}
|
|
</WorkspaceDeployLayout>
|
|
|
|
<DeploymentRequestPanel
|
|
bind:this={deploymentRequestPanel}
|
|
forkWorkspaceId={currentWorkspaceId}
|
|
{parentWorkspaceId}
|
|
currentUsername={$userStore?.username ?? ''}
|
|
isAdmin={$userStore?.is_admin ?? false}
|
|
onStateChange={(open) => {
|
|
hasOpenDeploymentRequest = open
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div class="bg-surface-tertiary p-4 rounded-md border">
|
|
<DatatableSchemaDiff {currentWorkspaceId} {parentWorkspaceId} />
|
|
</div>
|
|
</div>
|
|
|
|
<DiffDrawer bind:this={diffDrawer} {isFlow} />
|
|
|
|
<ScheduleEditor bind:this={scheduleEditor} />
|
|
<RouteEditor bind:this={routeEditor} />
|
|
<WebsocketTriggerEditor bind:this={websocketEditor} />
|
|
<KafkaTriggerEditor bind:this={kafkaEditor} />
|
|
<PostgresTriggerEditor bind:this={postgresEditor} />
|
|
<NatsTriggerEditor bind:this={natsEditor} />
|
|
<MqttTriggerEditor bind:this={mqttEditor} />
|
|
<SqsTriggerEditor bind:this={sqsEditor} />
|
|
<GcpTriggerEditor bind:this={gcpEditor} />
|
|
<AzureTriggerEditor bind:this={azureEditor} />
|
|
<EmailTriggerEditor bind:this={emailEditor} />
|
|
|
|
<ConfirmationModal
|
|
title="Delete trigger"
|
|
confirmationText="Delete"
|
|
open={!!triggerToDelete}
|
|
onConfirmed={confirmDeleteTrigger}
|
|
onCanceled={() => (triggerToDelete = undefined)}
|
|
>
|
|
{#if triggerToDelete}
|
|
Are you sure you want to delete the {getTriggerDisplayName(triggerToDelete.triggerKind)} trigger
|
|
'{triggerToDelete.path}'?
|
|
{/if}
|
|
</ConfirmationModal>
|
|
{:else}
|
|
<div class="flex items-center justify-center h-full">
|
|
<div class="text-gray-500">No comparison data available</div>
|
|
</div>
|
|
{/if}
|