UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

189 lines (188 loc) 6.86 kB
/* * Copyright 2025 The Kubernetes Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import i18next from 'i18next'; export const initialState = {}; const controllers = new Map(); /** The amount of time to wait before allowing the action to be cancelled. */ export const CLUSTER_ACTION_GRACE_PERIOD = 5000; /** * Uses the callback to execute an action and dispatches actions * to update the UI based on the result. * * Gives the user 5 seconds to cancel the action before executing it. */ export const executeClusterAction = createAsyncThunk('clusterAction/execute', async (action, { dispatch, rejectWithValue }) => { const actionKey = (new Date().getTime() + Math.random()).toString(); /** * See the handler for clusterAction/cancel/ in extraReducers below. */ const uniqueCancelActionType = 'clusterAction/cancel/' + actionKey; const controller = new AbortController(); controllers.set(actionKey, controller); const { callback, startUrl, cancelUrl, successUrl, startMessage, cancelledMessage, errorMessage, errorUrl, successMessage, callbackArgs, cancelCallback, startOptions = {}, cancelledOptions = {}, successOptions = { variant: 'success' }, errorOptions = { variant: 'error' }, } = action; // Dispatch actions for all the states. function dispatchStart() { dispatch(updateClusterAction({ id: actionKey, key: actionKey, message: startMessage, state: 'start', url: startUrl, buttons: [ { label: i18next.t('translation|Cancel'), actionToDispatch: uniqueCancelActionType, }, ], snackbarProps: startOptions, })); } function dispatchSuccess() { dispatch(updateClusterAction({ buttons: undefined, dismissSnackbar: actionKey, id: actionKey, message: successMessage, state: 'complete', snackbarProps: successOptions, url: successUrl, })); } function dispatchCancelled() { dispatch(updateClusterAction({ buttons: undefined, id: actionKey, message: cancelledMessage, state: 'complete', dismissSnackbar: actionKey, url: cancelUrl, snackbarProps: cancelledOptions, autoHideDuration: 3000, })); } function dispatchError() { dispatch(updateClusterAction({ buttons: undefined, dismissSnackbar: actionKey, id: actionKey, message: errorMessage, state: 'error', snackbarProps: errorOptions, url: errorUrl, })); } function dispatchClose() { dispatch(updateClusterAction({ id: actionKey, })); } async function cancellableActionLogic() { dispatchStart(); try { await new Promise((resolve, reject) => { const timeoutId = setTimeout(resolve, CLUSTER_ACTION_GRACE_PERIOD); controller.signal.addEventListener('abort', () => { clearTimeout(timeoutId); reject('Action cancelled'); }); }); if (controller.signal.aborted) { return rejectWithValue('Action cancelled'); } if (callbackArgs === undefined) { await Promise.resolve(callback()); } else { await Promise.resolve(callback(...callbackArgs)); } dispatchSuccess(); } catch (err) { if (err.message === 'Action cancelled' || controller.signal.aborted) { dispatchCancelled(); if (cancelCallback) { try { cancelCallback(); } catch (err) { console.error(err); } } } else { dispatchError(); } } finally { controllers.delete(actionKey); setTimeout(dispatchClose, 3000); } } await cancellableActionLogic(); return actionKey; }); const clusterActionSlice = createSlice({ name: 'clusterAction', initialState, reducers: { /** * Updates the state of the action. * * If only id is provided, the action is removed from the state. */ updateClusterAction: (state, action) => { const { id, ...actionOptions } = action.payload; if (Object.keys(actionOptions).length === 0) { delete state[id]; } else { const { snackbarProps, ...otherActionOptions } = actionOptions; state[id] = { ...state[id], ...otherActionOptions, id }; state[id].snackbarProps = snackbarProps; // any because snackbarProps is problematic to ts } }, /** * Cancels the action with the given key. */ cancelClusterAction: (state, action) => { const actionKey = action.payload; const controller = controllers.get(actionKey); if (controller) { controller.abort(); controllers.delete(actionKey); } delete state[actionKey]; }, }, extraReducers: builder => { builder.addMatcher(action => action.type.startsWith('clusterAction/cancel/'), (state, action) => { const actionKey = action.type.split('clusterAction/cancel/')[1]; clusterActionSlice.caseReducers.cancelClusterAction(state, { type: 'clusterAction/cancelClusterAction', payload: actionKey, }); }); }, }); export const { updateClusterAction, cancelClusterAction } = clusterActionSlice.actions; /** * Executes the callback action with the given options. */ export function clusterAction(callback, actionOptions = {}) { return executeClusterAction({ callback, ...actionOptions }); } export default clusterActionSlice.reducer;