From eb7443d8461a9af7420e3d0e43a83714f773b87f Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Thu, 1 Oct 2026 13:34:59 +0200 Subject: [PATCH] feat: delete a data pipeline from the home list; pipeline rows match item rows --- .../assets/AssetGraph/pipelineDelete.ts | 126 +++++++++++++++ .../common/table/PipelineRow.svelte | 150 ++++++++++++++++++ .../src/lib/components/home/ItemsList.svelte | 13 +- .../src/lib/components/home/TreeView.svelte | 25 ++- 4 files changed, 291 insertions(+), 23 deletions(-) create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineDelete.ts create mode 100644 frontend/src/lib/components/common/table/PipelineRow.svelte diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineDelete.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineDelete.ts new file mode 100644 index 0000000000..c4c1f7e9ec --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineDelete.ts @@ -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 { + 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 { + 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 { + 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 } +} diff --git a/frontend/src/lib/components/common/table/PipelineRow.svelte b/frontend/src/lib/components/common/table/PipelineRow.svelte new file mode 100644 index 0000000000..1c5ed22901 --- /dev/null +++ b/frontend/src/lib/components/common/table/PipelineRow.svelte @@ -0,0 +1,150 @@ + + + + {#snippet actions()} + {#if !$userStore?.operator} + void openConfirm() + } + ]} + /> + {/if} + {/snippet} + + + (confirmOpen = false)} + on:confirmed={confirmDelete} +> +
+ {#if planError} + Could not list the pipeline: {planError} + {:else if !plan} + + Listing what this removes… + + {: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} + Nothing left to remove in this pipeline. + {/if} + + The tables and files the pipeline wrote are kept, and so is the folder f/{folder}. + + {/if} +
+
+ +{#snippet group(title: string, entries: string[])} +
+ {title} ({entries.length}) +
    + {#each entries as entry (entry)} +
  • {entry}
  • + {/each} +
+
+{/snippet} diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 1d9edc683b..980d26edce 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -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 @@
{#if filter === ''} {#each [...visiblePipelineFolders].sort() as folder (folder)} - - - Pipeline · f/{folder} - + {/each} {/if} {#each displayedItems as item, i (item.type + '/' + item.path + (item.hash ? '/' + item.hash : ''))} diff --git a/frontend/src/lib/components/home/TreeView.svelte b/frontend/src/lib/components/home/TreeView.svelte index 4c6f3e8ae6..806787cb76 100644 --- a/frontend/src/lib/components/home/TreeView.svelte +++ b/frontend/src/lib/components/home/TreeView.svelte @@ -1,14 +1,14 @@