From d26377f1a88e4a7a9c5efe3ae90f837aa9b6b6fa Mon Sep 17 00:00:00 2001 From: Karakatiza666 Date: Fri, 3 Jul 2026 21:34:18 +0000 Subject: [PATCH] [web-console] Fix incorrect column order in the pipelines table Fix tooltips for 'Duplicate' and 'Delete' actions Signed-off-by: Karakatiza666 --- .../src/lib/components/pipelines/Table.svelte | 17 ++--- .../components/pipelines/Table.svelte.spec.ts | 47 ++++++++++++- .../components/pipelines/list/Actions.svelte | 28 ++++++-- .../src/lib/compositions/duplicatePipeline.ts | 2 +- .../lib/functions/pipelines/status.spec.ts | 68 +++++++++++++++++++ .../src/lib/functions/pipelines/status.ts | 21 ++++++ 6 files changed, 164 insertions(+), 19 deletions(-) create mode 100644 js-packages/web-console/src/lib/functions/pipelines/status.spec.ts diff --git a/js-packages/web-console/src/lib/components/pipelines/Table.svelte b/js-packages/web-console/src/lib/components/pipelines/Table.svelte index 6240e633a96..f3a8bfdb679 100644 --- a/js-packages/web-console/src/lib/components/pipelines/Table.svelte +++ b/js-packages/web-console/src/lib/components/pipelines/Table.svelte @@ -175,7 +175,12 @@ Tags - + Runtime @@ -191,16 +196,6 @@ - - - Runtime - - const header = (label: string) => page.getByText(label, { exact: true }) const headerCell = (label: string) => header(label).element().closest('th')! +// The visible column order, read left to right off the header row. Each cell's +// text is whitespace-collapsed so responsive label variants (e.g. the short and +// long "Runtime errors" spans, both present in the DOM) compare deterministically. +const columnHeaders = () => + Array.from(document.querySelectorAll('thead th')).map((th) => + th.textContent!.replace(/\s+/g, ' ').trim() + ) + const persistedSort = () => JSON.parse(localStorage.getItem(SORT_KEY)!) const mountTable = () => render(Table, { props: { pipelines, selectedPipelines: [] } } as any) @@ -151,3 +160,39 @@ describe('Table — column sorting', () => { expect(headerCell('Pipeline name').classList.contains('active')).toBe(true) }) }) + +describe('Table — column order', () => { + beforeEach(() => { + localStorage.clear() + useLayoutSettings().pipelinesTableSort.value = { column: 'name', direction: 'asc' } + }) + + afterEach(async () => { + // See the sorting suite's afterEach: wait out @vincjo/datatables' 2 ms + // scroll-restore timer so it fires while the component is still mounted. + await new Promise((resolve) => setTimeout(resolve, 10)) + localStorage.clear() + }) + + it('renders the columns left to right in the expected order', async () => { + mountTable() + + // The leading cell is the select-all checkbox and carries no label. "Runtime + // errors" holds both the short ("Errors") and long ("Runtime errors") span, + // so its collapsed text is the concatenation of the two. + await expect + .poll(columnHeaders) + .toEqual([ + '', + 'Pipeline name', + 'Storage', + 'Status', + 'Message', + 'Tags', + 'Runtime version', + 'Errors Runtime errors', + 'Status changed', + 'Deployed on' + ]) + }) +}) diff --git a/js-packages/web-console/src/lib/components/pipelines/list/Actions.svelte b/js-packages/web-console/src/lib/components/pipelines/list/Actions.svelte index 9d7e9af1d40..bb3cd817056 100644 --- a/js-packages/web-console/src/lib/components/pipelines/list/Actions.svelte +++ b/js-packages/web-console/src/lib/components/pipelines/list/Actions.svelte @@ -62,7 +62,11 @@ groups related actions into multi-action dropdowns when multiple options are ava import { usePremiumFeatures } from '$lib/compositions/usePremiumFeatures.svelte' import { useToast } from '$lib/compositions/useToastNotification' import type { WritablePipeline } from '$lib/compositions/useWritablePipeline.svelte' - import { getDeploymentStatusLabel, isPipelineShutdown } from '$lib/functions/pipelines/status' + import { + deletePipelineDisabledReason, + getDeploymentStatusLabel, + isPipelineShutdown + } from '$lib/functions/pipelines/status' import { resolve } from '$lib/functions/svelte' import type { PipelineAction } from '$lib/services/pipelineManager' import type { Snippet } from '$lib/types/svelte' @@ -111,6 +115,10 @@ groups related actions into multi-action dropdowns when multiple options are ava } const { toastError } = useToast() + // An already-deleted pipeline offers no Delete action; otherwise the backend + // dictates when deletion is possible (fully stopped, storage cleared). + const deleteDisabledReason = $derived(deletePipelineDisabledReason(pipeline.current.status, pipeline.current.storageStatus, deleted)) + const actions = { _start, _start_paused, @@ -673,9 +681,8 @@ groups related actions into multi-action dropdowns when multiple options are ava class="bg-white-dark absolute right-0 z-30 mt-2 flex w-44 flex-col justify-stretch rounded shadow-md" > + + {#if unsavedChanges} + Save the program before duplicating. + {:else} + {duplicatePipelineTooltip} + {/if} + + {#if deleteDisabledReason} + {deleteDisabledReason} + {/if} {/snippet} diff --git a/js-packages/web-console/src/lib/compositions/duplicatePipeline.ts b/js-packages/web-console/src/lib/compositions/duplicatePipeline.ts index 6df844a1e13..68d9f0f63a5 100644 --- a/js-packages/web-console/src/lib/compositions/duplicatePipeline.ts +++ b/js-packages/web-console/src/lib/compositions/duplicatePipeline.ts @@ -3,7 +3,7 @@ import type { PipelineThumb } from '$lib/services/pipelineManager' import type { useUpdatePipelineList } from './pipelines/usePipelineList.svelte' export const duplicatePipelineTooltip = - 'Pipeline storage and state are not duplicated. The new pipeline starts fresh.' + 'The connectors are preserved. Pipeline storage and state are not copied - the new pipeline starts fresh.' export const MAX_DUPLICATE_ATTEMPTS = 10_000 diff --git a/js-packages/web-console/src/lib/functions/pipelines/status.spec.ts b/js-packages/web-console/src/lib/functions/pipelines/status.spec.ts new file mode 100644 index 00000000000..b2ac7a138d4 --- /dev/null +++ b/js-packages/web-console/src/lib/functions/pipelines/status.spec.ts @@ -0,0 +1,68 @@ +/** + * Unit tests for `deletePipelineDisabledReason`, the helper that names why a + * pipeline cannot be deleted. It mirrors the pipeline-manager's delete + * preconditions (fully stopped, storage cleared), so the tests walk the same + * two blockers and their resolution order. + */ +import { describe, expect, it } from 'vitest' +import type { StorageStatus } from '$lib/services/manager' +import type { PipelineStatus } from '$lib/services/pipelineManager' +import { deletePipelineDisabledReason } from './status' + +describe('deletePipelineDisabledReason', () => { + it('allows deletion once the pipeline is stopped and storage is cleared', () => { + expect(deletePipelineDisabledReason('Stopped', 'Cleared', false)).toBeUndefined() + }) + + it('reports when the pipeline is already deleted', () => { + expect(deletePipelineDisabledReason('Stopped', 'Cleared', true)).toBe( + 'This pipeline has already been deleted.' + ) + }) + + it('asks to stop the pipeline while it is still running', () => { + expect(deletePipelineDisabledReason('Running', 'Cleared', false)).toBe('Stop the pipeline to delete it.') + }) + + // Stop takes priority: a running pipeline always holds its storage, so naming + // storage first would send the user down a step they cannot take yet. + it('asks to stop first even when storage is still in use', () => { + expect(deletePipelineDisabledReason('Running', 'InUse', false)).toBe('Stop the pipeline to delete it.') + }) + + it('asks to clear storage when stopped but storage is in use', () => { + expect(deletePipelineDisabledReason('Stopped', 'InUse', false)).toBe( + 'Clear the pipeline storage to delete it.' + ) + }) + + it('asks to wait while storage is mid-clear', () => { + expect(deletePipelineDisabledReason('Stopped', 'Clearing', false)).toBe( + 'Wait for storage to finish clearing to delete the pipeline.' + ) + }) + + // A pipeline in an error state is fully stopped, so cleared storage unblocks + // deletion just as it does for the normal Stopped state. + it('treats error states as stopped', () => { + const errorStates: PipelineStatus[] = ['SqlError', 'RustError', 'SystemError'] + for (const status of errorStates) { + expect(deletePipelineDisabledReason(status, 'Cleared', false)).toBeUndefined() + expect(deletePipelineDisabledReason(status, 'InUse', false)).toBe( + 'Clear the pipeline storage to delete it.' + ) + } + }) + + // Transitional states are not fully stopped, so deletion stays blocked on stop + // regardless of the storage state. + it('blocks on stop for transitional states', () => { + const transitional: PipelineStatus[] = ['Pausing', 'Resuming', 'Stopping', 'Suspending'] + const anyStorage: StorageStatus[] = ['Cleared', 'InUse', 'Clearing'] + for (const status of transitional) { + for (const storage of anyStorage) { + expect(deletePipelineDisabledReason(status, storage, false)).toBe('Stop the pipeline to delete it.') + } + } + }) +}) diff --git a/js-packages/web-console/src/lib/functions/pipelines/status.ts b/js-packages/web-console/src/lib/functions/pipelines/status.ts index 5201b61435e..1f95cd1df63 100644 --- a/js-packages/web-console/src/lib/functions/pipelines/status.ts +++ b/js-packages/web-console/src/lib/functions/pipelines/status.ts @@ -1,4 +1,5 @@ import { match, P } from 'ts-pattern' +import type { StorageStatus } from '$lib/services/manager' import type { ExtendedPipeline, PipelineStatus } from '$lib/services/pipelineManager' export const getPipelineStatusLabel = (status: PipelineStatus) => { @@ -193,6 +194,26 @@ export const isPipelineShutdown = (status: PipelineStatus) => { .exhaustive() } +export const deletePipelineDisabledReason = ( + status: PipelineStatus, + storageStatus: StorageStatus, + deleted: boolean +): string | undefined => { + if (deleted) { + return 'This pipeline has already been deleted.' + } + if (!isPipelineShutdown(status)) { + return 'Stop the pipeline to delete it.' + } + if (storageStatus === 'Clearing') { + return 'Wait for storage to finish clearing to delete the pipeline.' + } + if (storageStatus !== 'Cleared') { + return 'Clear the pipeline storage to delete it.' + } + return undefined +} + export const isMetricsAvailable = (status: PipelineStatus) => { return match(status) .returnType<'yes' | 'no' | 'soon' | 'missing'>()