GeezerVison is showing github.com — [web-console] Let Start save unsaved changes and schedule the start · feldera/feldera@8972c2d
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: js-packages/web-console/src/lib/components/common/SplitButton.svelte +4Lines changed: 4 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 45 | 45 | size = 'md', | 46 | 46 | variant = 'filled', | 47 | 47 | toggleClass = '', | | 48 | + disabled = false, | 48 | 49 | class: _class = '' | 49 | 50 | }: { | 50 | 51 | /** The main action. Its button's size class should match `size`. */ | | 58 | 59 | variant?: SplitButtonVariant | 59 | 60 | /** Classes for the chevron only, typically the colour preset of the main button. */ | 60 | 61 | toggleClass?: string | | 62 | + /** Disables the chevron. Disable the main button in `children` separately. */ | | 63 | + disabled?: boolean | 61 | 64 | class?: string | 62 | 65 | } = $props() | 63 | 66 |
| | 74 | 77 | type="button" | 75 | 78 | class="btn-split-toggle btn-icon {toggleButtonClass}" | 76 | 79 | onclick={ontoggle} | | 80 | + {disabled} | 77 | 81 | aria-label={toggleLabel} | 78 | 82 | > | 79 | 83 | <span class="fd fd-chevron-down {toggleClasses[size].icon}"></span> | |
Collapse file Copy file name to clipboardExpand all lines: js-packages/web-console/src/lib/components/pipelines/editor/ActionsFixture.svelte +1-1Lines changed: 1 addition & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
| 31 | 31 | {deleted} | 32 | 32 | editConfigDisabled={deleted} | 33 | 33 | unsavedChanges={false} | 34 | | - saveFile={() => {}} | | 34 | + saveFile={async () => true} | 35 | 35 | ></Actions> |
Collapse file Copy file name to clipboardExpand all lines: js-packages/web-console/src/lib/components/pipelines/list/Actions.svelte +79-63Lines changed: 79 additions & 63 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -66,7 +66,10 @@ groups related actions into multi-action dropdowns when multiple options are ava | 66 | 66 | import { getPipelineAction } from '#lib/compositions/usePipelineAction.svelte.ts' | 67 | 67 | import { usePipelineManager } from '#lib/compositions/usePipelineManager.svelte.ts' | 68 | 68 | import { useToast } from '#lib/compositions/useToastNotification.ts' | 69 | | - import type { WritablePipeline } from '#lib/compositions/useWritablePipeline.svelte.ts' | | 69 | + import { | | 70 | + settlePendingPatches, | | 71 | + type WritablePipeline | | 72 | + } from '#lib/compositions/useWritablePipeline.svelte.ts' | 70 | 73 | import { | 71 | 74 | deletePipelineDisabledReason, | 72 | 75 | editSettingsDisabledReason, | @@ -96,7 +99,8 @@ groups related actions into multi-action dropdowns when multiple options are ava | 96 | 99 | deleted?: boolean | 97 | 100 | unsavedChanges: boolean | 98 | 101 | onActionSuccess?: (pipelineName: string, action: PipelineAction) => void | 99 | | - saveFile: () => void | | 102 | + /** Saves all unsaved files. Resolves to `false` if the user cancels the save. */ | | 103 | + saveFile: () => Promise<boolean> | 100 | 104 | class?: string | 101 | 105 | } = $props() | 102 | 106 |
| @@ -206,18 +210,19 @@ groups related actions into multi-action dropdowns when multiple options are ava | 206 | 210 |
| 207 | 211 | // Helper function to get raw actions for current pipeline status | 208 | 212 | const getRawActions = (status: typeof pipeline.current.status) => { | | 213 | + const startable: (keyof typeof actions)[] = [ | | 214 | + '_start', | | 215 | + '_start_paused', | | 216 | + '_standby', | | 217 | + '_storage_indicator', | | 218 | + '_saveFile', | | 219 | + '_configurations', | | 220 | + '_more' | | 221 | + ] | 209 | 222 | return filterByPermission( | 210 | 223 | match(status) | 211 | 224 | .returnType<(keyof typeof actions)[]>() | 212 | | - .with('Stopped', () => [ | 213 | | - '_start', | 214 | | - '_start_paused', | 215 | | - '_standby', | 216 | | - '_storage_indicator', | 217 | | - '_saveFile', | 218 | | - '_configurations', | 219 | | - '_more' | 220 | | - ]) | | 225 | + .with('Stopped', () => startable) | 221 | 226 | .with('Preparing', 'Provisioning', 'Initializing', () => [ | 222 | 227 | '_kill', | 223 | 228 | '_spinner', | @@ -318,22 +323,24 @@ groups related actions into multi-action dropdowns when multiple options are ava | 318 | 323 | { CompilingSql: P.any }, | 319 | 324 | { SqlCompiled: P.any }, | 320 | 325 | { CompilingRust: P.any }, | 321 | | - (cause) => [ | 322 | | - ...(Object.values(cause)[0].cause === 'upgrade' ? ['_unschedule' as const] : []), | 323 | | - '_start_pending', | 324 | | - '_storage_indicator', | 325 | | - '_saveFile', | 326 | | - '_configurations', | 327 | | - '_more' | 328 | | - ] | | 326 | + (status) => | | 327 | + Object.values(status)[0].cause === 'start' | | 328 | + ? [ | | 329 | + '_unschedule', | | 330 | + '_start_pending', | | 331 | + '_storage_indicator', | | 332 | + '_saveFile', | | 333 | + '_configurations', | | 334 | + '_more' | | 335 | + ] | | 336 | + : startable | | 337 | + ) | | 338 | + // Unsaved changes may fix the error, so Start saves them and schedules a start. | | 339 | + .with('SqlError', 'RustError', 'SystemError', () => | | 340 | + unsavedChanges | | 341 | + ? startable | | 342 | + : ['_start_error', '_storage_indicator', '_saveFile', '_configurations', '_more'] | 329 | 343 | ) | 330 | | - .with('SqlError', 'RustError', 'SystemError', () => [ | 331 | | - '_start_error', | 332 | | - '_storage_indicator', | 333 | | - '_saveFile', | 334 | | - '_configurations', | 335 | | - '_more' | 336 | | - ]) | 337 | 344 | .exhaustive() | 338 | 345 | ) | 339 | 346 | } | @@ -419,11 +426,24 @@ groups related actions into multi-action dropdowns when multiple options are ava | 419 | 426 | const { postPipelineAction } = getPipelineAction() | 420 | 427 | const pipelineActionCallbacks = usePipelineActionCallbacks() | 421 | 428 |
| | 429 | + // Save the code and wait for its PATCH requests before the start request, because | | 430 | + // pipeline-manager rejects code changes after a start request. Resolves to `false` | | 431 | + // if the user cancels the save or a PATCH request fails. | | 432 | + const saveBeforeStart = async () => | | 433 | + (await saveFile()) && | | 434 | + settlePendingPatches().then( | | 435 | + () => true, | | 436 | + () => false | | 437 | + ) | | 438 | +
| 422 | 439 | const performStartAction = async ( | 423 | 440 | action: PipelineAction, | 424 | 441 | pipelineName: string | 425 | 442 | // nextStatus: PipelineStatus | 426 | 443 | ) => { | | 444 | + if (!(await saveBeforeStart())) { | | 445 | + return | | 446 | + } | 427 | 447 | const callbacks = | 428 | 448 | action === 'start' | 429 | 449 | ? { | @@ -479,8 +499,7 @@ groups related actions into multi-action dropdowns when multiple options are ava | 479 | 499 | const pipelineName = pipeline.current.name | 480 | 500 | performStartAction('start', pipelineName) | 481 | 501 | }, | 482 | | - disabled: () => unsavedChanges, | 483 | | - disabledText: 'Save First', | | 502 | + disabled: () => false, | 484 | 503 | standaloneButton: _start | 485 | 504 | }, | 486 | 505 | _start_paused: { | @@ -490,8 +509,7 @@ groups related actions into multi-action dropdowns when multiple options are ava | 490 | 509 | const pipelineName = pipeline.current.name | 491 | 510 | performStartAction('start_paused', pipelineName) | 492 | 511 | }, | 493 | | - disabled: () => unsavedChanges, | 494 | | - disabledText: 'Save First', | | 512 | + disabled: () => false, | 495 | 513 | standaloneButton: _start_paused | 496 | 514 | }, | 497 | 515 | _resume: { | @@ -501,8 +519,7 @@ groups related actions into multi-action dropdowns when multiple options are ava | 501 | 519 | const pipelineName = pipeline.current.name | 502 | 520 | performStartAction('resume', pipelineName) | 503 | 521 | }, | 504 | | - disabled: () => unsavedChanges, | 505 | | - disabledText: 'Save First', | | 522 | + disabled: () => false, | 506 | 523 | standaloneButton: _start | 507 | 524 | }, | 508 | 525 | _stop: { | @@ -537,16 +554,11 @@ groups related actions into multi-action dropdowns when multiple options are ava | 537 | 554 | _standby: { | 538 | 555 | label: 'Start in Standby', | 539 | 556 | description: 'Put the pipeline in standby mode', | 540 | | - onclick: async () => { | | 557 | + onclick: () => { | 541 | 558 | const pipelineName = pipeline.current.name | 542 | | - const { waitFor } = await postPipelineAction(pipelineName, 'standby') | 543 | | - waitFor().then( | 544 | | - (shouldContinue) => shouldContinue && onActionSuccess?.(pipelineName, 'standby'), | 545 | | - toastError('Waiting for pipeline to standby') | 546 | | - ) | | 559 | + performStartAction('standby', pipelineName) | 547 | 560 | }, | 548 | | - disabled: () => unsavedChanges, | 549 | | - disabledText: 'Save First', | | 561 | + disabled: () => false, | 550 | 562 | standaloneButton: _standby | 551 | 563 | }, | 552 | 564 | _activate: { | @@ -813,59 +825,56 @@ groups related actions into multi-action dropdowns when multiple options are ava | 813 | 825 | {#snippet _start()} | 814 | 826 | {@render start({ | 815 | 827 | text: 'Start', | 816 | | - action: 'start', | 817 | | - disabled: unsavedChanges | | 828 | + action: 'start' | 818 | 829 | })} | 819 | | - {#if unsavedChanges} | 820 | | - <Tooltip placement="top">Save the program before running</Tooltip> | 821 | | - {/if} | 822 | 830 | {/snippet} | 823 | 831 | {#snippet _start_paused()} | 824 | 832 | {@render start({ | 825 | 833 | text: 'Start', | 826 | | - action: 'start_paused', | 827 | | - disabled: unsavedChanges | | 834 | + action: 'start_paused' | 828 | 835 | })} | 829 | | - {#if unsavedChanges} | 830 | | - <Tooltip placement="top">Save the program before running</Tooltip> | 831 | | - {/if} | 832 | 836 | {/snippet} | 833 | 837 | {#snippet _resume()} | 834 | 838 | {@render start({ | 835 | 839 | text: 'Resume', | 836 | | - action: 'resume', | 837 | | - disabled: unsavedChanges | | 840 | + action: 'resume' | 838 | 841 | })} | 839 | | - {#if unsavedChanges} | 840 | | - <Tooltip placement="top">Save the program before running</Tooltip> | 841 | | - {/if} | 842 | 842 | {/snippet} | 843 | 843 | {#snippet _standby()} | 844 | 844 | {@render start({ | 845 | 845 | text: 'Standby', | 846 | | - action: 'standby', | 847 | | - disabled: unsavedChanges | | 846 | + action: 'standby' | 848 | 847 | })} | 849 | 848 | <Tooltip placement="top">Put the pipeline in standby mode</Tooltip> | 850 | 849 | {/snippet} | 851 | 850 | {#snippet _activate()} | 852 | 851 | {@render start({ | 853 | 852 | text: 'Activate', | 854 | | - action: 'activate', | 855 | | - disabled: unsavedChanges | | 853 | + action: 'activate' | 856 | 854 | })} | 857 | 855 | <Tooltip placement="top">Activate the pipeline to start data ingress and processing</Tooltip> | 858 | 856 | {/snippet} | | 857 | +<!-- A split button, so that the disabled Start has the layout of the enabled one. --> | 859 | 858 | {#snippet _start_disabled()} | 860 | | - {@render start({ text: 'Start', disabled: true })} | | 859 | + {@const config = multiActionConfigs.start} | | 860 | + <SplitButton | | 861 | + ontoggle={() => {}} | | 862 | + toggleLabel={config.ariaLabel} | | 863 | + toggleClass={config.buttonClass} | | 864 | + variant={config.variant} | | 865 | + size={config.size} | | 866 | + disabled | | 867 | + > | | 868 | + {@render start({ text: 'Start', disabled: true })} | | 869 | + </SplitButton> | 861 | 870 | {/snippet} | 862 | 871 | {#snippet _start_error()} | 863 | 872 | {@render _start_disabled()} | 864 | 873 | <Tooltip placement="top">Resolve errors before running</Tooltip> | 865 | 874 | {/snippet} | 866 | 875 | {#snippet _start_pending()} | 867 | 876 | {@render _start_disabled()} | 868 | | - <Tooltip placement="top">Wait for compilation to complete</Tooltip> | | 877 | + <Tooltip placement="top">The pipeline starts after compilation</Tooltip> | 869 | 878 | {/snippet} | 870 | 879 | {#snippet _pause()} | 871 | 880 | <button | @@ -961,8 +970,15 @@ groups related actions into multi-action dropdowns when multiple options are ava | 961 | 970 | <!-- TODO: add support for a short size when in mobile --> | 962 | 971 | <button | 963 | 972 | class="{buttonClass} {longClass} {basicBtnColor}" | 964 | | - onclick={() => { | 965 | | - globalDialog.dialog = killDialog | | 973 | + onclick={async () => { | | 974 | + // The pipeline does not run before the compilation ends, so the kill needs no | | 975 | + // confirmation. | | 976 | + const pipelineName = pipeline.current.name | | 977 | + const { waitFor } = await postPipelineAction(pipelineName, 'kill') | | 978 | + waitFor().then( | | 979 | + (shouldContinue) => shouldContinue && onActionSuccess?.(pipelineName, 'kill'), | | 980 | + toastError('Waiting for the pipeline start to cancel') | | 981 | + ) | 966 | 982 | }} | 967 | 983 | > | 968 | 984 | Cancel start | |
Collapse file Copy file name to clipboardExpand all lines: js-packages/web-console/src/lib/components/pipelines/table/AvailableActions.svelte +4-5Lines changed: 4 additions & 5 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 56 | 56 | { CompilingSql: P.any }, | 57 | 57 | { SqlCompiled: P.any }, | 58 | 58 | { CompilingRust: P.any }, | 59 | | - (cause) => [ | 60 | | - ...(Object.values(cause)[0].cause === 'upgrade' ? ['kill' as const] : []), | 61 | | - ...storageAction, | 62 | | - 'delete' | 63 | | - ] | | 59 | + (status) => | | 60 | + Object.values(status)[0].cause === 'start' | | 61 | + ? ['kill', ...storageAction, 'delete'] | | 62 | + : ['start', ...storageAction, 'delete'] | 64 | 63 | ) | 65 | 64 | .with('Stopped', () => ['start', ...storageAction, 'delete']) | 66 | 65 | .with('Preparing', 'Provisioning', 'Initializing', () => ['kill', 'delete']) | |
Collapse file Copy file name to clipboardExpand all lines: js-packages/web-console/src/lib/compositions/usePipelineAction.svelte.ts +27-4Lines changed: 27 additions & 4 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | 1 | import type { NamesInUnion } from 'common-lib/union' | 2 | 2 | import { unionName } from 'common-lib/union' | 3 | 3 | import invariant from 'tiny-invariant' | 4 | | -import { match } from 'ts-pattern' | | 4 | +import { match, P } from 'ts-pattern' | 5 | 5 | import { hasSelectedRelations } from '#lib/components/pipelines/editor/TabChangeStream.svelte' | 6 | 6 | import type { | 7 | | - ExtendedPipeline, | 8 | 7 | PipelineAction, | 9 | 8 | PipelineStatus, | 10 | 9 | PipelineThumb | 11 | 10 | } from '#lib/services/pipelineManager.ts' | 12 | 11 | import { page } from '$app/state' | 13 | 12 | import { usePipelineList, useUpdatePipelineList } from './pipelines/usePipelineList.svelte' | 14 | | -import { type PipelineManagerApi, usePipelineManager } from './usePipelineManager.svelte' | | 13 | +import { usePipelineManager } from './usePipelineManager.svelte' | 15 | 14 | import { useReactiveWaiter } from './useReactiveWaiter.svelte' | 16 | 15 |
| 17 | 16 | let postPipelineAction: ( | @@ -97,9 +96,33 @@ export const usePipelineAction = () => { | 97 | 96 | .with('approve_changes', () => undefined) | 98 | 97 | .exhaustive() | 99 | 98 |
| | 99 | + // A start or a stop does not end a compilation. For a compiling pipeline, the | | 100 | + // optimistic status keeps the compiling state and only sets whether a start is | | 101 | + // scheduled. | | 102 | + const compilingStatus = (status: PipelineStatus) => { | | 103 | + const cause = match(action) | | 104 | + .returnType<'start' | 'compile' | undefined>() | | 105 | + .with('start', 'start_paused', 'standby', () => 'start') | | 106 | + .with('stop', 'kill', () => 'compile') | | 107 | + .otherwise(() => undefined) | | 108 | + if (!cause) { | | 109 | + return undefined | | 110 | + } | | 111 | + return match(status) | | 112 | + .returnType<PipelineStatus | undefined>() | | 113 | + .with({ Queued: P.any }, () => ({ Queued: { cause } })) | | 114 | + .with({ CompilingSql: P.any }, () => ({ CompilingSql: { cause } })) | | 115 | + .with({ SqlCompiled: P.any }, () => ({ SqlCompiled: { cause } })) | | 116 | + .with({ CompilingRust: P.any }, () => ({ CompilingRust: { cause } })) | | 117 | + .otherwise(() => undefined) | | 118 | + } | | 119 | + | 100 | 120 | // Apply optimistic updates | 101 | 121 | if (optimisticStatus) { | 102 | | - updatePipeline(pipeline_name, (p) => ({ ...p, status: optimisticStatus })) | | 122 | + updatePipeline(pipeline_name, (p) => ({ | | 123 | + ...p, | | 124 | + status: compilingStatus(p.status) ?? optimisticStatus | | 125 | + })) | 103 | 126 | } else if (action === 'clear') { | 104 | 127 | updatePipeline(pipeline_name, (p) => ({ ...p, storageStatus: 'Clearing' })) | 105 | 128 | } | |
Collapse file Copy file name to clipboardExpand all lines: js-packages/web-console/src/lib/compositions/useWritablePipeline.svelte.ts +11-1Lines changed: 11 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -5,6 +5,12 @@ import { | 5 | 5 | } from '#lib/compositions/usePipelineManager.svelte.ts' | 6 | 6 | import type { ExtendedPipeline, Pipeline, PipelineThumb } from '#lib/services/pipelineManager.ts' | 7 | 7 |
| | 8 | +// Module-level because `writablePipeline` is re-created whenever the pipeline changes. | | 9 | +const pendingPatches = new Set<Promise<unknown>>() | | 10 | + | | 11 | +/** Wait for every pipeline patch in flight. Rejects if one of them fails. */ | | 12 | +export const settlePendingPatches = () => Promise.all(pendingPatches) | | 13 | + | 8 | 14 | export const writablePipeline = ({ | 9 | 15 | api, | 10 | 16 | pipeline, | @@ -31,7 +37,11 @@ export const writablePipeline = ({ | 31 | 37 | if (optimistic) { | 32 | 38 | update(newPipeline) | 33 | 39 | } | 34 | | - const res = await api.patchPipeline(pipeline.current.name, newPipeline) | | 40 | + const request = api.patchPipeline(pipeline.current.name, newPipeline) | | 41 | + pendingPatches.add(request) | | 42 | + const forget = () => pendingPatches.delete(request) | | 43 | + request.then(forget, forget) | | 44 | + const res = await request | 35 | 45 | if (!optimistic) { | 36 | 46 | set(res) | 37 | 47 | } | |
|