feat: delete a data pipeline from the home list; pipeline rows match item rows

This commit is contained in:
Diego Imbert
2026-10-01 13:34:59 +02:00
parent 2d50f1f9b0
commit eb7443d846
4 changed files with 291 additions and 23 deletions
@@ -0,0 +1,126 @@
import { DraftService, OpenAPI, ScriptService, type RunnableItem } from '$lib/gen'
import { discardDraft } from '$lib/utils_draft_deploy'
import { PIPELINE_DRAFT_KIND, pipelineBundlePath, pipelineLocalMirrorKey } from '$lib/pipelinePaths'
import { deleteTriggerRow } from './pipelineTriggerDraftDeploy'
import type { AssetGraphResponse, NativeTriggerKind } from './types'
export type PipelineDeleteResult = { removed: number; failures: string[] }
/** The folder's pipeline-member scripts, deployed and draft-only alike: every one of
* them makes the home list show the pipeline, so one left behind brings it back. */
async function listPipelineScripts(workspace: string, folder: string): Promise<RunnableItem[]> {
const out: RunnableItem[] = []
let cursor: string | undefined
do {
const res = await ScriptService.listRunnables({
workspace,
kinds: 'script',
pathStart: `f/${folder}/`,
includeDraftOnly: true,
perPage: 100,
cursor
})
out.push(...res.items.filter((it) => it.auto_kind === 'pipeline'))
cursor = res.next_cursor ?? undefined
} while (cursor)
return out
}
async function listNativeTriggers(workspace: string, folder: string) {
const res = await fetch(
`${OpenAPI.BASE ?? ''}/w/${workspace}/assets/graph?${new URLSearchParams({ folder })}`,
{ credentials: 'include' }
)
if (!res.ok) throw new Error(`GET /assets/graph → ${res.status}`)
const graph = (await res.json()) as AssetGraphResponse
return graph.triggers.flatMap((t) =>
t.trigger_kind !== 'asset' &&
t.trigger_kind !== 'webhook' &&
t.trigger_kind !== 'data_upload' &&
t.path &&
!t.unsaved
? [{ kind: t.trigger_kind, path: t.path }]
: []
)
}
export type PipelineDeletePlan = {
scripts: { path: string; draftOnly: boolean }[]
triggers: { kind: NativeTriggerKind; path: string }[]
/** Whether the caller has an unsaved pipeline draft for this folder. */
hasDraft: boolean
}
/** Everything `deletePipeline` would remove, so it can be listed before it is. */
export async function planPipelineDelete(
workspace: string,
folder: string
): Promise<PipelineDeletePlan> {
const [scripts, triggers, draft] = await Promise.all([
listPipelineScripts(workspace, folder),
listNativeTriggers(workspace, folder),
DraftService.getOwnDraft({
workspace,
kind: PIPELINE_DRAFT_KIND,
path: pipelineBundlePath(folder)
}).catch(() => undefined)
])
return {
scripts: scripts.map((s) => ({
path: s.draft_only ? s.draft_path || s.path : s.path,
draftOnly: !!s.draft_only
})),
triggers,
hasDraft: !!draft
}
}
/** Removes what `plan` lists: the trigger rows, the scripts (deleted when
* `hardDelete`, which the backend allows admins only, archived otherwise; a
* draft-only node is discarded) and the caller's pipeline draft. The data the
* nodes wrote (DuckLake tables, S3 objects) is left alone. Triggers go first, so
* a refused script never leaves a schedule firing at a path that is gone. */
export async function deletePipeline(
workspace: string,
folder: string,
plan: PipelineDeletePlan,
hardDelete: boolean
): Promise<PipelineDeleteResult> {
const failures: string[] = []
let removed = 0
const fail = (what: string, e: any) =>
failures.push(`${what}: ${e?.body ?? e?.message ?? String(e)}`)
for (const t of plan.triggers) {
try {
if (await deleteTriggerRow(t.kind, t.path, workspace)) removed++
} catch (e) {
fail(`${t.kind} trigger ${t.path}`, e)
}
}
for (const s of plan.scripts) {
try {
if (s.draftOnly) {
const r = await discardDraft('script', s.path, workspace, true, false, false)
if (!r.success) throw new Error(r.error)
} else if (hardDelete) {
await ScriptService.deleteScriptByPath({ workspace, path: s.path })
} else {
await ScriptService.archiveScriptByPath({ workspace, path: s.path })
}
removed++
} catch (e) {
fail(s.path, e)
}
}
if (plan.hasDraft) {
const bundle = await discardDraft(PIPELINE_DRAFT_KIND, pipelineBundlePath(folder), workspace, true)
if (!bundle.success) failures.push(`pipeline draft: ${bundle.error}`)
}
try {
localStorage.removeItem(pipelineLocalMirrorKey(folder))
} catch {
// Storage unavailable: nothing was mirrored either.
}
return { removed, failures }
}
@@ -0,0 +1,150 @@
<script lang="ts">
import { base } from '$lib/base'
import Dropdown from '$lib/components/DropdownV2.svelte'
import ConfirmationModal from '../confirmationModal/ConfirmationModal.svelte'
import {
deletePipeline,
planPipelineDelete,
type PipelineDeletePlan
} from '$lib/components/assets/AssetGraph/pipelineDelete'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { Loader2, Trash } from 'lucide-svelte'
import Row from './Row.svelte'
interface Props {
folder: string
depth?: number
/** Shown instead of `f/<folder>`, e.g. under the folder's own tree node. */
label?: string
onDeleted?: () => void
}
let { folder, depth = 0, label, onDeleted }: Props = $props()
let confirmOpen = $state(false)
let deleting = $state(false)
let plan = $state<PipelineDeletePlan | undefined>(undefined)
let planError = $state<string | undefined>(undefined)
// The backend deletes scripts for admins only; everyone else archives them.
let hardDelete = $derived(!!($userStore?.is_admin || $userStore?.is_super_admin))
async function openConfirm() {
const ws = $workspaceStore
if (!ws) return
plan = undefined
planError = undefined
confirmOpen = true
try {
plan = await planPipelineDelete(ws, folder)
} catch (e: any) {
planError = e?.body ?? e?.message ?? String(e)
}
}
async function confirmDelete() {
const ws = $workspaceStore
if (!ws || !plan) return
deleting = true
try {
const { removed, failures } = await deletePipeline(ws, folder, plan, hardDelete)
if (failures.length) {
sendUserToast(`Pipeline f/${folder} was only partly removed`, true, [], failures.join('\n'))
} else {
sendUserToast(`Removed pipeline f/${folder} (${removed} item${removed === 1 ? '' : 's'})`)
}
onDeleted?.()
} catch (e: any) {
sendUserToast(`Could not remove pipeline f/${folder}: ${e?.body ?? e?.message ?? e}`, true)
} finally {
deleting = false
confirmOpen = false
}
}
</script>
<Row
kind="data_pipeline"
marked={undefined}
href="{base}/pipeline/{encodeURIComponent(folder)}"
workspaceId={$workspaceStore ?? ''}
path={`f/${folder}`}
summary={label ?? `Pipeline · ${folder}`}
canFavorite={false}
{depth}
>
{#snippet actions()}
{#if !$userStore?.operator}
<Dropdown
items={[
{
displayName: 'Delete',
icon: Trash,
type: 'delete',
action: () => void openConfirm()
}
]}
/>
{/if}
{/snippet}
</Row>
<ConfirmationModal
open={confirmOpen}
title="Delete pipeline f/{folder}"
confirmationText={hardDelete ? 'Delete' : 'Archive'}
loading={deleting}
confirmDisabled={!plan}
trashbin={hardDelete}
on:canceled={() => (confirmOpen = false)}
on:confirmed={confirmDelete}
>
<div class="flex flex-col gap-3 text-xs">
{#if planError}
<span class="text-red-600 dark:text-red-400">Could not list the pipeline: {planError}</span>
{:else if !plan}
<span class="flex items-center gap-2 text-secondary">
<Loader2 size={14} class="animate-spin" /> Listing what this removes…
</span>
{:else}
{#if plan.scripts.some((s) => !s.draftOnly)}
{@render group(
hardDelete ? 'Scripts deleted' : 'Scripts archived',
plan.scripts.filter((s) => !s.draftOnly).map((s) => s.path)
)}
{/if}
{#if plan.scripts.some((s) => s.draftOnly)}
{@render group(
'Draft scripts discarded',
plan.scripts.filter((s) => s.draftOnly).map((s) => s.path)
)}
{/if}
{#if plan.triggers.length}
{@render group(
'Triggers deleted',
plan.triggers.map((t) => `${t.path} (${t.kind})`)
)}
{/if}
{#if plan.hasDraft}
{@render group('Unsaved pipeline changes discarded', ['Your pipeline draft'])}
{/if}
{#if !plan.scripts.length && !plan.triggers.length && !plan.hasDraft}
<span class="text-secondary">Nothing left to remove in this pipeline.</span>
{/if}
<span class="text-secondary">
The tables and files the pipeline wrote are kept, and so is the folder f/{folder}.
</span>
{/if}
</div>
</ConfirmationModal>
{#snippet group(title: string, entries: string[])}
<div class="flex flex-col gap-1">
<span class="font-semibold text-emphasis">{title} ({entries.length})</span>
<ul class="max-h-40 overflow-auto rounded-md border bg-surface-secondary px-3 py-1.5">
{#each entries as entry (entry)}
<li class="font-mono text-2xs text-primary truncate">{entry}</li>
{/each}
</ul>
</div>
{/snippet}
@@ -56,8 +56,7 @@
import { effectivePath, type ItemType } from './treeViewUtils'
import { tick, untrack } from 'svelte'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import { NetworkIcon } from 'lucide-svelte'
import { base } from '$lib/base'
import PipelineRow from '../common/table/PipelineRow.svelte'
import BulkActionsBar from './BulkActionsBar.svelte'
import { HomeSelection, setHomeSelection, toBulkItem } from './homeSelection.svelte'
interface Props {
@@ -707,6 +706,8 @@
// keeping a dead path. `tick` lets the reloaded rows re-register first.
const renderedBefore = homeSelection.renderedKeys
void ownerCountsRes.refetch()
// Deleting a pipeline removes it from this list, which no item reload covers.
void pipelineFoldersRes.refetch()
await reloadItems()
await tick()
homeSelection.dropVanished(renderedBefore)
@@ -2002,13 +2003,7 @@
<div class="border rounded-md bg-surface-tertiary" class:wm-imported={justImported}>
{#if filter === ''}
{#each [...visiblePipelineFolders].sort() as folder (folder)}
<a
href="{base}/pipeline/{encodeURIComponent(folder)}"
class="w-full inline-flex items-center gap-4 px-4 py-3 border-b last:border-b-0 hover:bg-surface-hover transition-colors text-sm first-of-type:rounded-t-md"
>
<NetworkIcon size={16} class="text-emerald-600 dark:text-emerald-400" />
<span class="text-xs font-medium text-emphasis truncate">Pipeline · f/{folder}</span>
</a>
<PipelineRow {folder} onDeleted={reloadItemsAndCounts} />
{/each}
{/if}
{#each displayedItems as item, i (item.type + '/' + item.path + (item.hash ? '/' + item.hash : ''))}
@@ -1,14 +1,14 @@
<script lang="ts">
import TreeView from './TreeView.svelte'
import { onDestroy, untrack, type Snippet } from 'svelte'
import { createEventDispatcher, onDestroy, untrack, type Snippet } from 'svelte'
import ResizeTransitionWrapper from '$lib/components/common/ResizeTransitionWrapper.svelte'
import { ChevronDown, ChevronUp, Folder, FolderTree, NetworkIcon, User } from 'lucide-svelte'
import { ChevronDown, ChevronUp, Folder, FolderTree, User } from 'lucide-svelte'
import PipelineRow from '../common/table/PipelineRow.svelte'
import Item from './Item.svelte'
import { countLeaves, type FolderItem, type ItemType, type UserItem } from './treeViewUtils'
import { twMerge } from 'tailwind-merge'
import { pluralize } from '$lib/utils'
import { base } from '$lib/base'
import { Button } from '$lib/components/common'
interface Props {
@@ -74,6 +74,8 @@
let visualDepth = $derived(depth + indent)
const dispatch = createEventDispatcher<{ reload: void }>()
// Bounds the request burst from "expand all": however many root owners the tree
// renders (its slice grows as you scroll), it fetches at most this many. Lazy owners
// past the cap stay collapsed and load on a single click (see the effect).
@@ -316,17 +318,12 @@
{#if opened || isSearching}
<div>
{#if hasPipeline && isFolder(item)}
<!-- py-3 matches common/table/Row.svelte so this row sits at
the same height as the script/flow/app rows that follow
it under the same folder; py-2 was visibly shorter. -->
<a
href="{base}/pipeline/{encodeURIComponent(item.folderName)}"
class="flex items-center gap-4 px-4 py-3 border-b text-sm hover:bg-surface-hover transition-colors"
style="padding-left: {(visualDepth + 1) * 16}px;"
>
<NetworkIcon size={16} class="text-emerald-600 dark:text-emerald-400" />
<span class="text-xs font-medium text-emphasis">Pipeline</span>
</a>
<PipelineRow
folder={item.folderName}
depth={visualDepth + 1}
label="Pipeline"
onDeleted={() => dispatch('reload')}
/>
{/if}
{#each item.items.slice(0, effectiveMax) as subItem, index ((subItem['path'] ? subItem['type'] + '__' + subItem['path'] + '__' + index : undefined) ?? 'folder__' + subItem['folderName'] + '__' + index)}
<TreeView