From f0a60488d3cec5e4514d0a7b975c37cfd98a2939 Mon Sep 17 00:00:00 2001 From: yolossn Date: Mon, 18 Dec 2023 10:15:53 +0530 Subject: [PATCH 01/10] plugins frontend: Add registerPluginSettings method this patch adds a new registerPluginSettings method that stores the provided component in the pluginSettings redux store. Signed-off-by: yolossn --- frontend/src/plugin/pluginSlice.test.tsx | 71 ++++++++++++++++++++++++ frontend/src/plugin/pluginsSlice.ts | 63 ++++++++++++++++++++- frontend/src/plugin/registry.tsx | 57 +++++++++++++++++++ plugins/headlamp-plugin/src/index.ts | 14 ++++- 4 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 frontend/src/plugin/pluginSlice.test.tsx diff --git a/frontend/src/plugin/pluginSlice.test.tsx b/frontend/src/plugin/pluginSlice.test.tsx new file mode 100644 index 0000000000..805f6f31e3 --- /dev/null +++ b/frontend/src/plugin/pluginSlice.test.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { + PluginInfo, + PluginSettingsComponentType, + pluginsSlice, + PluginsState, +} from './pluginsSlice'; + +// initial state for the plugins slice +const initialState: PluginsState = { + /** Once the plugins have been fetched and executed. */ + loaded: false, + /** If plugin settings are saved use those. */ + pluginSettings: JSON.parse(localStorage.getItem('headlampPluginSettings') || '[]'), +}; + +// Mock React component for testing +const MockComponent: React.FC = () =>
New Component
; + +describe('pluginsSlice reducers', () => { + const { setPluginSettingsComponent } = pluginsSlice.actions; + + test('should handle setting a new plugin settings component when plugin name matches', () => { + const existingPluginName = 'test-plugin'; + const initialStateWithPlugin: PluginsState = { + ...initialState, + pluginSettings: [ + { + name: existingPluginName, + settingsComponent: undefined, + displaySettingsComponentWithSaveButton: false, + } as PluginInfo, + ], + }; + + const action = setPluginSettingsComponent({ + name: existingPluginName, + component: MockComponent as PluginSettingsComponentType, + displaySaveButton: true, + }); + + const newState = pluginsSlice.reducer(initialStateWithPlugin, action); + + expect(newState.pluginSettings[0].settingsComponent).toBeDefined(); + expect(newState.pluginSettings[0].displaySettingsComponentWithSaveButton).toBe(true); + }); + + test('should not modify state when plugin name does not match any existing plugin', () => { + const nonExistingPluginName = 'non-existing-plugin'; + const initialStateWithPlugin: PluginsState = { + ...initialState, + pluginSettings: [ + { + name: 'existing-plugin', + settingsComponent: undefined, + displaySettingsComponentWithSaveButton: false, + } as PluginInfo, + ], + }; + + const action = setPluginSettingsComponent({ + name: nonExistingPluginName, + component: MockComponent as PluginSettingsComponentType, + displaySaveButton: true, + }); + + const newState = pluginsSlice.reducer(initialStateWithPlugin, action); + + expect(newState).toEqual(initialStateWithPlugin); + }); +}); diff --git a/frontend/src/plugin/pluginsSlice.ts b/frontend/src/plugin/pluginsSlice.ts index 25ef796369..0ba3f9fdc5 100644 --- a/frontend/src/plugin/pluginsSlice.ts +++ b/frontend/src/plugin/pluginsSlice.ts @@ -1,4 +1,30 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import React, { ReactElement } from 'react'; + +/** + * Props for PluginSettingsDetailsProps component. + */ +export interface PluginSettingsDetailsProps { + /** + * Callback function to be triggered when there's a change in data. + * @param data - The updated data object. + */ + onDataChange?: (data: { [key: string]: any }) => void; + + /** + * Data object representing the current state/configuration. + * readonly - The data object is readonly and cannot be modified. + */ + readonly data?: { [key: string]: any }; +} + +/** + * PluginSettingsComponentType is the type of the component associated with the plugin's settings. + */ +export type PluginSettingsComponentType = + | React.ComponentType + | ReactElement + | null; /** * PluginInfo is the shape of the metadata information for individual plugin objects. @@ -35,6 +61,17 @@ export type PluginInfo = { devDependencies?: { [key: string]: string; }; + + /** + * Component associated with the plugin's settings. + */ + settingsComponent?: PluginSettingsComponentType; + + /** + * If the plugin settings should be displayed with a save button. + * + */ + displaySettingsComponentWithSaveButton?: boolean; }; export interface PluginsState { @@ -68,9 +105,33 @@ export const pluginsSlice = createSlice({ reloadPage() { window.location.reload(); }, + /** + * Set the plugin settings component. + */ + setPluginSettingsComponent( + state, + action: PayloadAction<{ + name: string; + component: PluginSettingsComponentType; + displaySaveButton: boolean; + }> + ) { + const { name, component, displaySaveButton } = action.payload; + state.pluginSettings = state.pluginSettings.map(plugin => { + if (plugin.name === name) { + return { + ...plugin, + settingsComponent: component, + displaySettingsComponentWithSaveButton: displaySaveButton, + }; + } + return plugin; + }); + }, }, }); -export const { pluginsLoaded, setPluginSettings, reloadPage } = pluginsSlice.actions; +export const { pluginsLoaded, setPluginSettings, setPluginSettingsComponent, reloadPage } = + pluginsSlice.actions; export default pluginsSlice.reducer; diff --git a/frontend/src/plugin/registry.tsx b/frontend/src/plugin/registry.tsx index 9ab268983b..bf8fd37a01 100644 --- a/frontend/src/plugin/registry.tsx +++ b/frontend/src/plugin/registry.tsx @@ -58,6 +58,11 @@ import { } from '../redux/headlampEventSlice'; import { setRoute, setRouteFilter } from '../redux/routesSlice'; import store from '../redux/stores/store'; +import { + PluginSettingsComponentType, + PluginSettingsDetailsProps, + setPluginSettingsComponent, +} from './pluginsSlice'; export interface SectionFuncProps { title: string; @@ -89,6 +94,8 @@ export type { ResourceDetailsViewLoadedEvent, ResourceListViewLoadedEvent, EventListEvent, + PluginSettingsDetailsProps, + PluginSettingsComponentType, }; export const DefaultHeadlampEvents = HeadlampEventType; export const DetailsViewDefaultHeaderActions = DefaultHeaderAction; @@ -623,4 +630,54 @@ export function registerHeadlampEventCallback(callback: HeadlampEventCallback) { store.dispatch(addEventCallback(callback)); } +/** + * Register a plugin settings component. + * + * @param name - The name of the plugin. + * @param component - The component to use for the settings. + * @param displaySaveButton - Whether to display the save button. + * @returns void + * + * @example + * + * ```tsx + * import { registerPluginSettings } from '@kinvolk/headlamp-plugin/lib'; + * import { TextField } from '@mui/material'; + * + * function MyPluginSettingsComponent(props: PluginSettingsDetailsProps) { + * const { data, onDataChange } = props; + * + * function onChange(value: string) { + * if (onDataChange) { + * onDataChange({ works: value }); + * } + * } + * + * return ( + * onChange(e.target.value)} + * label="Normal Input" + * variant="outlined" + * fullWidth + * /> + * ); + * } + * + * const displaySaveButton = true; + * // Register a plugin settings component. + * registerPluginSettings('my-plugin', MyPluginSettingsComponent, displaySaveButton); + * ``` + * + * More complete plugin settings example in plugins/examples/change-logo: + * @see {@link https://github.com/headlamp-k8s/headlamp/tree/main/plugins/examples/change-logo Change Logo Example} + */ +export function registerPluginSettings( + name: string, + component: PluginSettingsComponentType, + displaySaveButton: boolean = false +) { + store.dispatch(setPluginSettingsComponent({ name, component, displaySaveButton })); +} + export { DefaultAppBarAction, DefaultDetailsViewSection, getHeadlampAPIHeaders, runCommand }; diff --git a/plugins/headlamp-plugin/src/index.ts b/plugins/headlamp-plugin/src/index.ts index 01524a89b0..394aa25fc2 100644 --- a/plugins/headlamp-plugin/src/index.ts +++ b/plugins/headlamp-plugin/src/index.ts @@ -1,7 +1,6 @@ - import { Theme } from '@mui/material/styles'; -declare module "@mui/private-theming" { +declare module '@mui/private-theming' { interface DefaultTheme extends Theme {} } @@ -12,6 +11,7 @@ import * as Notification from './lib/notification'; import * as Router from './lib/router'; import * as Utils from './lib/util'; import { Headlamp, Plugin } from './plugin/lib'; +import { PluginSettingsDetailsProps } from './plugin/pluginsSlice'; import Registry, { AppLogoProps, ClusterChooserProps, @@ -26,6 +26,7 @@ import Registry, { registerDetailsViewHeaderActionsProcessor, registerDetailsViewSection, registerGetTokenFunction, + registerPluginSettings, registerResourceTableColumnsProcessor, registerRoute, registerRouteFilter, @@ -59,6 +60,13 @@ export { registerDetailsViewHeaderActionsProcessor, registerGetTokenFunction, registerResourceTableColumnsProcessor, + registerPluginSettings, }; -export type { AppLogoProps, ClusterChooserProps, DetailsViewSectionProps, DefaultSidebars }; +export type { + AppLogoProps, + PluginSettingsDetailsProps, + ClusterChooserProps, + DetailsViewSectionProps, + DefaultSidebars, +}; From ecc5251e5bf00f3d34cd4cfb97013d8b2b3b166a Mon Sep 17 00:00:00 2001 From: yolossn Date: Mon, 29 Jan 2024 11:44:31 +0530 Subject: [PATCH 02/10] backend: Add Delete plugin Endpoint Signed-off-by: yolossn --- backend/cmd/headlamp.go | 18 +++++++++++++ backend/cmd/headlamp_test.go | 40 +++++++++++++++++++++++++++++ backend/pkg/plugins/plugins.go | 27 +++++++++++++++++++ backend/pkg/plugins/plugins_test.go | 39 ++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+) diff --git a/backend/cmd/headlamp.go b/backend/cmd/headlamp.go index bf3aaeb58f..99824db358 100644 --- a/backend/cmd/headlamp.go +++ b/backend/cmd/headlamp.go @@ -245,6 +245,24 @@ func defaultKubeConfigPersistenceFile() (string, error) { // It serves plugin static files at “/plugins/” and “/static-plugins/”. // It disables caching and reloads plugin list base paths if not in-cluster. func addPluginRoutes(config *HeadlampConfig, r *mux.Router) { + // Delete plugin route + // This is only available when running locally. + if !config.useInCluster { + r.HandleFunc("/plugins/{name}", func(w http.ResponseWriter, r *http.Request) { + if err := checkHeadlampBackendToken(w, r); err != nil { + return + } + pluginName := mux.Vars(r)["name"] + + err := plugins.Delete(config.pluginDir, pluginName) + if err != nil { + http.Error(w, "Error deleting plugin", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + }).Methods("DELETE") + } + r.HandleFunc("/plugins", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") pluginsList, err := config.cache.Get(context.Background(), plugins.PluginListKey) diff --git a/backend/cmd/headlamp_test.go b/backend/cmd/headlamp_test.go index c1b19e95f0..2b950678fd 100644 --- a/backend/cmd/headlamp_test.go +++ b/backend/cmd/headlamp_test.go @@ -511,3 +511,43 @@ func TestDrainAndCordonNode(t *testing.T) { } } } + +func TestDeletePlugin(t *testing.T) { + // create temp dir for plugins + tempDir, err := os.MkdirTemp("", "plugins") + require.NoError(t, err) + + defer os.RemoveAll(tempDir) + + // create plugin + pluginDir := tempDir + "/test-plugin" + err = os.Mkdir(pluginDir, 0o755) + require.NoError(t, err) + + // create plugin file + pluginFile := pluginDir + "/main.js" + _, err = os.Create(pluginFile) + require.NoError(t, err) + + cache := cache.New[interface{}]() + kubeConfigStore := kubeconfig.NewContextStore() + + c := HeadlampConfig{ + useInCluster: false, + kubeConfigPath: config.GetDefaultKubeConfigPath(), + cache: cache, + kubeConfigStore: kubeConfigStore, + pluginDir: tempDir, + } + + handler := createHeadlampHandler(&c) + + rr, err := getResponseFromRestrictedEndpoint(handler, "DELETE", "/plugins/test-plugin", nil) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, rr.Code) + + // check if plugin was deleted + _, err = os.Stat(pluginDir) + assert.True(t, os.IsNotExist(err)) +} diff --git a/backend/pkg/plugins/plugins.go b/backend/pkg/plugins/plugins.go index 5c47a458b1..3d11d7e64c 100644 --- a/backend/pkg/plugins/plugins.go +++ b/backend/pkg/plugins/plugins.go @@ -7,7 +7,9 @@ import ( "log" "net/http" "os" + "path" "path/filepath" + "strings" "time" "github.com/fsnotify/fsnotify" @@ -212,3 +214,28 @@ func HandlePluginReload(cache cache.Cache[interface{}], w http.ResponseWriter) { } } } + +// Delete deletes the plugin from the plugin directory. +func Delete(pluginDir, filename string) error { + absPluginDir, err := filepath.Abs(pluginDir) + if err != nil { + return err + } + + absPluginPath := path.Join(absPluginDir, filename) + + if !isSubdirectory(absPluginDir, absPluginPath) { + return fmt.Errorf("plugin path '%s' is not a subdirectory of '%s'", absPluginPath, absPluginDir) + } + + return os.RemoveAll(absPluginPath) +} + +func isSubdirectory(parentDir, dirPath string) bool { + rel, err := filepath.Rel(parentDir, dirPath) + if err != nil { + return false + } + + return !strings.HasPrefix(rel, "..") && !strings.HasPrefix(rel, ".") +} diff --git a/backend/pkg/plugins/plugins_test.go b/backend/pkg/plugins/plugins_test.go index 32d7f181bc..f41fc19772 100644 --- a/backend/pkg/plugins/plugins_test.go +++ b/backend/pkg/plugins/plugins_test.go @@ -319,3 +319,42 @@ func TestPopulatePluginsCache(t *testing.T) { require.True(t, ok) require.Empty(t, pluginListArr) } + +// TestDelete checks the Delete function. +func TestDelete(t *testing.T) { + tempDir, err := os.MkdirTemp("", "testdelete") + require.NoError(t, err) + + defer os.RemoveAll(tempDir) // clean up + + // Create a temporary file + tempFile, err := os.CreateTemp(tempDir, "testfile") + require.NoError(t, err) + tempFile.Close() // Close the file + + // Test cases + tests := []struct { + pluginDir string + pluginName string + expectErr bool + }{ + {pluginDir: tempDir, pluginName: tempFile.Name(), expectErr: false}, // Existing file + {pluginDir: tempDir, pluginName: "non-existent-directory", expectErr: false}, // Non-existent file + {pluginDir: tempDir, pluginName: "../", expectErr: true}, // Directory traversal + + } + + for _, tt := range tests { + tt := tt + t.Run(tt.pluginName, func(t *testing.T) { + err := plugins.Delete(tt.pluginDir, tt.pluginName) + if tt.expectErr { + assert.Error(t, err, "Delete should return an error") + } else { + // check if the file exists + _, err := os.Stat(path.Join(tt.pluginDir, tt.pluginName)) + assert.True(t, os.IsNotExist(err), "File should not exist") + } + }) + } +} From 449bc7f57a4464c99f04ed80b6602efa418bb03f Mon Sep 17 00:00:00 2001 From: yolossn Date: Mon, 29 Jan 2024 12:49:56 +0530 Subject: [PATCH 03/10] frontend: Add plugin config store. this patch adds plugin config store that is responsible for handling the storage and retrieval of plugin configs Signed-off-by: yolossn --- frontend/src/plugin/configStore.ts | 69 ++++++++++++++++++++++ frontend/src/plugin/index.ts | 2 + frontend/src/plugin/pluginConfigSlice.ts | 75 ++++++++++++++++++++++++ frontend/src/redux/reducers/reducers.tsx | 2 + 4 files changed, 148 insertions(+) create mode 100644 frontend/src/plugin/configStore.ts create mode 100644 frontend/src/plugin/pluginConfigSlice.ts diff --git a/frontend/src/plugin/configStore.ts b/frontend/src/plugin/configStore.ts new file mode 100644 index 0000000000..c20251fb04 --- /dev/null +++ b/frontend/src/plugin/configStore.ts @@ -0,0 +1,69 @@ +import { useSelector } from 'react-redux'; +import store from '../redux/stores/store'; +import { setPluginConfig, updatePluginConfig } from './pluginConfigSlice'; + +/** + * A class to manage the configuration state for plugins in a Redux store. + * + * @template T - The type of the configuration object. + */ +export class ConfigStore { + private configKey: string; + + /** + * Creates an instance of the ConfigStore class. + * + * @param {string} configKey - The key to identify the specific plugin configuration. + */ + constructor(configKey: string) { + this.configKey = configKey; + } + + /** + * Sets the entire configuration for a specific plugin. + * + * This method will overwrite the entire configuration object for the given key. + * + * @param {T} configValue - The new configuration object. + */ + public set(configValue: T) { + store.dispatch( + setPluginConfig({ configKey: this.configKey, payload: configValue as { [key: string]: any } }) + ); + } + + /** + * Updates the configuration for a specific plugin. + * + * This method will merge the provided partial updates into the current configuration object. + * + * @param {Partial} partialUpdates - An object containing the updates to be merged into the current configuration. + */ + public update(partialUpdates: Partial) { + store.dispatch(updatePluginConfig({ configKey: this.configKey, payload: partialUpdates })); + } + + /** + * Retrieves the current configuration for the specified key from the Redux store. + * + * @returns The current configuration object. + */ + public get(): T { + const state: any = store.getState(); + return state?.pluginConfigs?.[this.configKey] as T; + } + + /** + * Creates a custom React hook for accessing the plugin's configuration state reactively. + * + * This hook allows components to access and react to changes in the plugin's configuration. + * + * @returns A custom React hook that returns the configuration state. + */ + public useConfig() { + const configKey = this.configKey; // Capture the configKey for closure + return function useConfigHook(): T { + return useSelector((state: any) => state?.pluginConfigs?.[configKey] as T); + }; + } +} diff --git a/frontend/src/plugin/index.ts b/frontend/src/plugin/index.ts index 36daaa2546..6d21b2da1f 100644 --- a/frontend/src/plugin/index.ts +++ b/frontend/src/plugin/index.ts @@ -7,6 +7,7 @@ import semver from 'semver'; import helpers from '../helpers'; import { eventAction, HeadlampEventType } from '../redux/headlampEventSlice'; import store from '../redux/stores/store'; +import { ConfigStore } from './configStore'; import { Headlamp, Plugin } from './lib'; import { PluginInfo } from './pluginsSlice'; import Registry, * as registryToExport from './registry'; @@ -18,6 +19,7 @@ window.pluginLib = { ? 'monaco-editor/esm/vs/editor/editor.api.js' : 'monaco-editor'), K8s: require('../lib/k8s'), + ConfigStore: ConfigStore, // Anything that is part of the lib/k8s/ folder should be imported after the K8s import, to // avoid circular dependencies' issues. Crd: require('../lib/k8s/crd'), diff --git a/frontend/src/plugin/pluginConfigSlice.ts b/frontend/src/plugin/pluginConfigSlice.ts new file mode 100644 index 0000000000..842dd37235 --- /dev/null +++ b/frontend/src/plugin/pluginConfigSlice.ts @@ -0,0 +1,75 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import _ from 'lodash'; + +/** + * The state structure for storing plugin configurations. + * Each plugin configuration is stored under a unique key. + */ +export interface PluginConfigState { + [configKey: string]: { [key: string]: any }; +} + +// Key used for local storage to persist plugin configurations. +const PLUGIN_CONFIG_KEY = 'pluginConfigs'; + +// Initial state is loaded from local storage, or an empty object if nothing is stored. +const initialState: PluginConfigState = JSON.parse(localStorage.getItem(PLUGIN_CONFIG_KEY) || '{}'); + +const DEBOUNCE_DELAY = 500; // ms + +const debouncedSetItemInLocalStorage = _.debounce((key: string, value: string) => { + try { + localStorage.setItem(key, value); + } catch (error) { + console.error('Error occurred while setting item in local storage:', error); + } +}, DEBOUNCE_DELAY); + +/** + * Slice for handling plugin configurations. + * Includes reducers to set and update configurations, which are automatically persisted to local storage. + */ +export const pluginConfigSlice = createSlice({ + name: 'pluginConfig', + initialState, + + reducers: { + /** + * Sets the configuration for a specific plugin. + * This will overwrite the entire configuration for the given key. + * The updated state is persisted to local storage. + * + * @param state - The current state of the plugin configurations. + * @param action - An action containing the config key and the new configuration object. + */ + setPluginConfig( + state, + action: PayloadAction<{ configKey: string; payload: { [key: string]: any } }> + ) { + state[action.payload.configKey] = action.payload.payload; + debouncedSetItemInLocalStorage(PLUGIN_CONFIG_KEY, JSON.stringify(state)); + }, + + /** + * Updates the configuration for a specific plugin. + * This will merge the provided updates into the current configuration for the given key. + * The updated state is persisted to local storage. + * + * @param state - The current state of the plugin configurations. + * @param action - An action containing the config key and the partial updates to be merged. + */ + updatePluginConfig( + state, + action: PayloadAction<{ configKey: string; payload: { [key: string]: any } }> + ) { + state[action.payload.configKey] = { + ...state[action.payload.configKey], + ...action.payload.payload, + }; + debouncedSetItemInLocalStorage(PLUGIN_CONFIG_KEY, JSON.stringify(state)); + }, + }, +}); + +export const { setPluginConfig, updatePluginConfig } = pluginConfigSlice.actions; +export default pluginConfigSlice.reducer; diff --git a/frontend/src/redux/reducers/reducers.tsx b/frontend/src/redux/reducers/reducers.tsx index 48595a96e1..7ce7debc45 100644 --- a/frontend/src/redux/reducers/reducers.tsx +++ b/frontend/src/redux/reducers/reducers.tsx @@ -12,6 +12,7 @@ import routesReducer from '../routesSlice'; import resourceTableReducer from './../../components/common/Resource/resourceTableSlice'; import detailsViewSectionReducer from './../../components/DetailsViewSection/detailsViewSectionSlice'; import sidebarReducer from './../../components/Sidebar/sidebarSlice'; +import pluginConfigReducer from './../../plugin/pluginConfigSlice'; import uiReducer from './ui'; const reducers = combineReducers({ @@ -29,6 +30,7 @@ const reducers = combineReducers({ sidebar: sidebarReducer, detailsViewSections: detailsViewSectionReducer, eventCallbackReducer, + pluginConfigs: pluginConfigReducer, }); export type RootState = ReturnType; From dc008271374c8e282bd7ce6675812ec5bd0145f3 Mon Sep 17 00:00:00 2001 From: yolossn Date: Mon, 29 Jan 2024 12:52:15 +0530 Subject: [PATCH 04/10] frontend: Add deletePlugin func to apiProxy this patch adds deletePlugin function to apiProxy, it calls the delete plugin endpoint with all necessary headers to handle auth. Signed-off-by: yolossn --- frontend/src/lib/k8s/apiProxy.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/frontend/src/lib/k8s/apiProxy.ts b/frontend/src/lib/k8s/apiProxy.ts index 5fbd60e41c..9ad29a0b2f 100644 --- a/frontend/src/lib/k8s/apiProxy.ts +++ b/frontend/src/lib/k8s/apiProxy.ts @@ -1830,3 +1830,25 @@ function getClusterDefaultNamespace(cluster: string, checkSettings?: boolean): s return defaultNamespace; } + +/** + * Deletes the plugin with the specified name from the system. This function sends a DELETE request to the server's plugin management endpoint, targeting the plugin identified by its name.The function handles the request asynchronously and returns a promise that resolves with the server's response to the DELETE operation. + * + * @param {string} name - The unique name of the plugin to delete. This identifier is used to construct the URL for the DELETE request. + * @returns — A Promise that resolves to the JSON response from the API server. + * @throws — An ApiError if the response status is not ok. + * + * @example + * // Call to delete a plugin named 'examplePlugin' + * deletePlugin('examplePlugin') + * .then(response => console.log('Plugin deleted successfully', response)) + * .catch(error => console.error('Failed to delete plugin', error)); + */ +export function deletePlugin(name: string) { + return request( + `/plugins/${name}`, + { method: 'DELETE', headers: { ...getHeadlampAPIHeaders() } }, + false, + false + ); +} From 8d97e492dd16695728e5202b326712afc1a2c5f8 Mon Sep 17 00:00:00 2001 From: yolossn Date: Mon, 29 Jan 2024 18:40:29 +0530 Subject: [PATCH 05/10] frontend: Update Plugin Settings view. this patch updates the plugin settings view, filter is added to the table, Origin now can be configured to link to a custom URL and enable buttons UI is improved. Signed-off-by: yolossn --- .../App/PluginSettings/PluginSettings.tsx | 111 +- .../PluginSettings.stories.storyshot | 1699 ++++++++++++++--- frontend/src/i18n/locales/en/translation.json | 5 + frontend/src/plugin/pluginsSlice.ts | 4 + 4 files changed, 1497 insertions(+), 322 deletions(-) diff --git a/frontend/src/components/App/PluginSettings/PluginSettings.tsx b/frontend/src/components/App/PluginSettings/PluginSettings.tsx index 6c09a4a415..5692da600b 100644 --- a/frontend/src/components/App/PluginSettings/PluginSettings.tsx +++ b/frontend/src/components/App/PluginSettings/PluginSettings.tsx @@ -1,12 +1,15 @@ -import { Switch } from '@mui/material'; +import { Switch, SwitchProps, Typography, useTheme } from '@mui/material'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; +import Link from '@mui/material/Link'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; +import { useFilterFunc } from '../../../lib/util'; import { PluginInfo, reloadPage, setPluginSettings } from '../../../plugin/pluginsSlice'; import { useTypedSelector } from '../../../redux/reducers/reducers'; import { SectionBox, SimpleTable } from '../../common'; +import SectionFilterHeader from '../../common/SectionFilterHeader'; /** * Interface of the component's props structure. @@ -26,6 +29,65 @@ export interface PluginSettingsPureProps { /** PluginSettingsProp intentially left empty to remain malleable */ export interface PluginSettingsProps {} +const EnableSwitch = (props: SwitchProps) => { + const theme = useTheme(); + + return ( + + ); +}; + /** PluginSettingsPure is the main component to where we render the plugin data. */ export function PluginSettingsPure(props: PluginSettingsPureProps) { const { t } = useTranslation(['translation']); @@ -98,28 +160,57 @@ export function PluginSettingsPure(props: PluginSettingsPureProps) { return ( <> - + } + > { + return ( + <> + {plugin.name} + {plugin.version} + + ); + }, + sort: (a, b) => a.name.localeCompare(b.name), }, { - label: 'Description', + label: t('translation|Description'), datum: 'description', + sort: true, + }, + { + label: t('translation|Origin'), + getter: plugin => { + const url = plugin?.homepage || plugin?.repository?.url; + return plugin?.origin ? ( + url ? ( + {plugin?.origin} + ) : ( + plugin?.origin + ) + ) : ( + t('translation|Unknown') + ); + }, + sort: true, }, + // TODO: Fetch the plugin status from the plugin settings store { - label: 'Homepage', + label: t('translation|Status'), getter: plugin => { - return plugin.homepage ? plugin.homepage : plugin?.repository?.url; + return plugin.isEnabled ? t('translation|Enabled') : t('translation|Disabled'); }, + sort: true, }, { - label: 'Enable', + label: t('translation|Enable'), getter: plugin => { return ( - switchChangeHanlder(plugin)} @@ -128,9 +219,11 @@ export function PluginSettingsPure(props: PluginSettingsPureProps) { /> ); }, + sort: (a, b) => (a.isEnabled === b.isEnabled ? 0 : a.isEnabled ? -1 : 1), }, ]} data={pluginChanges} + filterFunction={useFilterFunc(['.name'])} /> {enableSave && ( diff --git a/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.stories.storyshot b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.stories.storyshot index 7a2841e2e2..8d7618077f 100644 --- a/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.stories.storyshot +++ b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.stories.storyshot @@ -14,16 +14,39 @@ exports[`Storyshots Settings/PluginSettings Default Save Enable 1`] = `
-

Plugins -

+
+
+
+
+ +
+
+
Name + Description + + + + Origin + - Homepage + Status + Enable + @@ -75,7 +159,19 @@ exports[`Storyshots Settings/PluginSettings Default Save Enable 1`] = ` - plugin a 0 +
+ + plugin a 0 + +
+ - https://example.com/plugin-link-0 + Unknown + + + Enabled - - plugin a 1 +
+ + plugin a 1 + +
+ - https://example.com/plugin-link-1 + Unknown + + + Disabled - - plugin a 2 +
+ + plugin a 2 + +
+ - https://example.com/plugin-link-2 + Unknown + + + Enabled - - plugin a 3 +
+ + plugin a 3 + +
+ - https://example.com/plugin-link-3 + Unknown + + + Disabled - - plugin a 4 +
+ + plugin a 4 + +
+ - https://example.com/plugin-link-4 + Unknown + + + Enabled - -

Plugins -

+
+
+
+
+ +
+
+
-

Plugins -

+
+
+
+
+ +
+
+
Name + Description + - Homepage + Origin + + Status + + + Enable + @@ -445,7 +706,19 @@ exports[`Storyshots Settings/PluginSettings Empty Homepage Items 1`] = ` - plugin a 0 +
+ + plugin a 0 + +
+ - https://example.com/plugin-link-0 + Unknown + + + Enabled - - plugin a 1 +
+ + plugin a 1 + +
+ - https://example.com/plugin-link-1 + Unknown + + + Disabled - - plugin a 2 +
+ + plugin a 2 + +
+ - https://example.com/plugin-link-2 + Unknown + + + Enabled - - plugin a 3 +
+ + plugin a 3 + +
+ - https://example.com/plugin-link-3 + Unknown + + + Disabled - - plugin a 4 +
+ + plugin a 4 + +
+ - https://example.com/plugin-link-4 + Unknown + + + Enabled - -

Plugins -

+
+
+
+
+ +
+
+
Name + Description + - Homepage + Origin + + Status + + + Enable + @@ -755,7 +1170,19 @@ exports[`Storyshots Settings/PluginSettings Few Items 1`] = ` - plugin a 0 +
+ + plugin a 0 + +
+ - https://example.com/plugin-link-0 + Unknown + + + Enabled - - plugin a 1 +
+ + plugin a 1 + +
+ - https://example.com/plugin-link-1 + Unknown + + + Disabled - - plugin a 2 +
+ + plugin a 2 + +
+ - https://example.com/plugin-link-2 + Unknown + + + Enabled - - plugin a 3 +
+ + plugin a 3 + +
+ - https://example.com/plugin-link-3 + Unknown + + + Disabled - - plugin a 4 +
+ + plugin a 4 + +
+ - https://example.com/plugin-link-4 + Unknown + + + Enabled - -

Plugins -

+
+
+
+
+ +
+
+
Name + Description + - Homepage + Origin + + Status + + + Enable + @@ -1065,7 +1634,19 @@ exports[`Storyshots Settings/PluginSettings Many Items 1`] = ` - plugin a 0 +
+ + plugin a 0 + +
+ - https://example.com/plugin-link-0 + Unknown + + + Enabled - - plugin a 1 +
+ + plugin a 1 + +
+ - https://example.com/plugin-link-1 + Unknown + + + Disabled - - plugin a 2 +
+ + plugin a 2 + +
+ - https://example.com/plugin-link-2 + Unknown + + + Enabled - - plugin a 3 +
+ + plugin a 3 + +
+ - https://example.com/plugin-link-3 + Unknown + + + Disabled - - plugin a 4 +
+ + plugin a 4 + +
+ - https://example.com/plugin-link-4 + Unknown + + + Enabled - - plugin a 5 +
+ + plugin a 5 + +
+ - https://example.com/plugin-link-5 + Unknown + + + Disabled - - plugin a 6 +
+ + plugin a 6 + +
+ - https://example.com/plugin-link-6 + Unknown + + + Enabled - - plugin a 7 +
+ + plugin a 7 + +
+ - https://example.com/plugin-link-7 + Unknown + + + Disabled - - plugin a 8 +
+ + plugin a 8 + +
+ - https://example.com/plugin-link-8 + Unknown + + + Enabled - - plugin a 9 +
+ + plugin a 9 + +
+ - https://example.com/plugin-link-9 + Unknown + + + Disabled - - plugin a 10 +
+ + plugin a 10 + +
+ - https://example.com/plugin-link-10 + Unknown + + + Enabled - - plugin a 11 +
+ + plugin a 11 + +
+ - https://example.com/plugin-link-11 + Unknown + + + Disabled - - plugin a 12 +
+ + plugin a 12 + +
+ - https://example.com/plugin-link-12 + Unknown + + + Enabled - - plugin a 13 +
+ + plugin a 13 + +
+ - https://example.com/plugin-link-13 + Unknown + + + Disabled - - plugin a 14 +
+ + plugin a 14 + +
+ - https://example.com/plugin-link-14 + Unknown + + + Enabled - -

Plugins -

+
+
+
+
+ +
+
+
Name + Description + + + + Origin + - Homepage + Status + Enable + @@ -1840,7 +2703,19 @@ exports[`Storyshots Settings/PluginSettings More Items 1`] = ` - plugin a 0 +
+ + plugin a 0 + +
+ - https://example.com/plugin-link-0 + Unknown + + + Enabled - - plugin a 1 +
+ + plugin a 1 + +
+ - https://example.com/plugin-link-1 + Unknown + + + Disabled - - plugin a 2 +
+ + plugin a 2 + +
+ - https://example.com/plugin-link-2 + Unknown + + + Enabled - - plugin a 3 +
+ + plugin a 3 + +
+ - https://example.com/plugin-link-3 + Unknown + + + Disabled - - plugin a 4 +
+ + plugin a 4 + +
+ - https://example.com/plugin-link-4 + Unknown + + + Enabled - - plugin a 5 +
+ + plugin a 5 + +
+ - https://example.com/plugin-link-5 + Unknown + + + Disabled - - plugin a 6 +
+ + plugin a 6 + +
+ - https://example.com/plugin-link-6 + Unknown + + + Enabled - - plugin a 7 +
+ + plugin a 7 + +
+ - https://example.com/plugin-link-7 + Unknown + + + Disabled - - plugin a 8 +
+ + plugin a 8 + +
+ - https://example.com/plugin-link-8 + Unknown + + + Enabled - - plugin a 9 +
+ + plugin a 9 + +
+ - https://example.com/plugin-link-9 + Unknown + + + Disabled - - plugin a 10 +
+ + plugin a 10 + +
+ - https://example.com/plugin-link-10 + Unknown + + + Enabled - - plugin a 11 +
+ + plugin a 11 + +
+ - https://example.com/plugin-link-11 + Unknown + + + Disabled - - plugin a 12 +
+ + plugin a 12 + +
+ - https://example.com/plugin-link-12 + Unknown + + + Enabled - - plugin a 13 +
+ + plugin a 13 + +
+ - https://example.com/plugin-link-13 + Unknown + + + Disabled - - plugin a 14 +
+ + plugin a 14 + +
+ - https://example.com/plugin-link-14 + Unknown + + + Enabled - Date: Tue, 30 Jan 2024 00:12:14 +0530 Subject: [PATCH 06/10] frontend: Add PluginDetails page this commit adds PluginDetails page and storybook for it. Signed-off-by: yolossn --- .../App/PluginSettings/PluginSettings.tsx | 12 +- .../PluginSettingsDetails.stories.tsx | 88 ++++++ .../PluginSettings/PluginSettingsDetails.tsx | 200 ++++++++++++++ .../PluginSettingsDetails.stories.storyshot | 256 ++++++++++++++++++ .../__snapshots__/Settings.stories.storyshot | 2 +- frontend/src/i18n/locales/de/translation.json | 10 +- frontend/src/i18n/locales/en/translation.json | 15 +- frontend/src/i18n/locales/es/translation.json | 10 +- frontend/src/i18n/locales/fr/translation.json | 10 +- frontend/src/i18n/locales/pt/translation.json | 10 +- frontend/src/lib/router.tsx | 13 + 11 files changed, 613 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/App/PluginSettings/PluginSettingsDetails.stories.tsx create mode 100644 frontend/src/components/App/PluginSettings/PluginSettingsDetails.tsx create mode 100644 frontend/src/components/App/PluginSettings/__snapshots__/PluginSettingsDetails.stories.storyshot diff --git a/frontend/src/components/App/PluginSettings/PluginSettings.tsx b/frontend/src/components/App/PluginSettings/PluginSettings.tsx index 5692da600b..dfe2196b9a 100644 --- a/frontend/src/components/App/PluginSettings/PluginSettings.tsx +++ b/frontend/src/components/App/PluginSettings/PluginSettings.tsx @@ -8,7 +8,7 @@ import { useDispatch } from 'react-redux'; import { useFilterFunc } from '../../../lib/util'; import { PluginInfo, reloadPage, setPluginSettings } from '../../../plugin/pluginsSlice'; import { useTypedSelector } from '../../../redux/reducers/reducers'; -import { SectionBox, SimpleTable } from '../../common'; +import { Link as HeadlampLink, SectionBox, SimpleTable } from '../../common'; import SectionFilterHeader from '../../common/SectionFilterHeader'; /** @@ -170,7 +170,15 @@ export function PluginSettingsPure(props: PluginSettingsPureProps) { getter: plugin => { return ( <> - {plugin.name} + + + {plugin.name} + + {plugin.version} ); diff --git a/frontend/src/components/App/PluginSettings/PluginSettingsDetails.stories.tsx b/frontend/src/components/App/PluginSettings/PluginSettingsDetails.stories.tsx new file mode 100644 index 0000000000..8ac35ff235 --- /dev/null +++ b/frontend/src/components/App/PluginSettings/PluginSettingsDetails.stories.tsx @@ -0,0 +1,88 @@ +import { TextField } from '@mui/material'; +import { Meta, Story } from '@storybook/react'; +import React from 'react'; +import { PluginInfo, PluginSettingsDetailsProps } from '../../../plugin/pluginsSlice'; +import { PluginSettingsDetailsPure, PluginSettingsDetailsPureProps } from './PluginSettingsDetails'; + +const testAutoSaveComponent: React.FC = () => { + const [data, setData] = React.useState<{ [key: string]: any }>({}); + const onChange = (value: string) => { + setData({ works: value }); + }; + + return ( + onChange(e.target.value)} + label="Normal Input" + variant="outlined" + fullWidth + /> + ); +}; + +const testNormalComponent: React.FC = props => { + const { data, onDataChange } = props; + + function onChange(value: string) { + if (onDataChange) { + onDataChange({ works: value }); + } + } + + return ( + onChange(e.target.value)} + label="Normal Input" + variant="outlined" + fullWidth + /> + ); +}; + +// Mock PluginInfo data +const mockPluginInfoAutoSave: PluginInfo = { + name: 'Example Plugin AutoSave', + description: 'This is an example plugin with auto-save enabled.', + version: '0.0.1', + homepage: 'https://example.com/plugin-auto-save', + settingsComponent: testAutoSaveComponent, + displaySettingsComponentWithSaveButton: false, +}; + +const mockPluginInfoNormal: PluginInfo = { + name: 'Example Plugin Normal', + description: 'This is an example plugin with normal save.', + version: '0.0.1', + homepage: 'https://example.com/plugin-normal', + settingsComponent: testNormalComponent, + displaySettingsComponentWithSaveButton: true, +}; + +const mockConfig = { + name: 'mockPlugin', +}; + +const Template: Story = (args: PluginSettingsDetailsPureProps) => ( + +); + +export const WithAutoSave = Template.bind({}); +WithAutoSave.args = { + plugin: mockPluginInfoAutoSave, + onDelete: () => console.log('Delete action'), +}; + +export const WithoutAutoSave = Template.bind({}); +WithoutAutoSave.args = { + config: mockConfig, + plugin: mockPluginInfoNormal, + onSave: (data: { [key: string]: any }) => console.log('Save data:', data), + onDelete: () => console.log('Delete action'), +}; + +export default { + title: 'Settings/PluginSettingsDetail', + component: PluginSettingsDetailsPure, +} as Meta; diff --git a/frontend/src/components/App/PluginSettings/PluginSettingsDetails.tsx b/frontend/src/components/App/PluginSettings/PluginSettingsDetails.tsx new file mode 100644 index 0000000000..46f57905bb --- /dev/null +++ b/frontend/src/components/App/PluginSettings/PluginSettingsDetails.tsx @@ -0,0 +1,200 @@ +import Box, { BoxProps } from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Stack from '@mui/material/Stack'; +import _ from 'lodash'; +import { isValidElement, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; +import { useParams } from 'react-router-dom'; +import helpers from '../../../helpers'; +import { deletePlugin } from '../../../lib/k8s/apiProxy'; +import { ConfigStore } from '../../../plugin/configStore'; +import { PluginInfo, reloadPage } from '../../../plugin/pluginsSlice'; +import { useTypedSelector } from '../../../redux/reducers/reducers'; +import NotFoundComponent from '../../404'; +import { SectionBox } from '../../common'; +import { ConfirmDialog } from '../../common/Dialog'; +import ErrorBoundary from '../../common/ErrorBoundary'; + +const PluginSettingsDetailsInitializer = (props: { plugin: PluginInfo }) => { + const { plugin } = props; + const store = new ConfigStore(plugin.name); + const pluginConf = store.useConfig(); + const config = pluginConf() as { [key: string]: any }; + + function handleSave(data: { [key: string]: any }) { + store.set(data); + } + + function handleDeleteConfirm() { + const name = plugin.name.split('/').splice(-1)[0]; + deletePlugin(name) + .then(() => { + // update the plugin list + const dispatch = useDispatch(); + dispatch(reloadPage()); + }) + .finally(() => { + // redirect /plugins page + window.location.pathname = '/settings/plugins'; + }); + } + + return ( + + ); +}; + +export default function PluginSettingsDetails() { + const pluginSettings = useTypedSelector(state => state.plugins.pluginSettings); + const { name } = useParams<{ name: string }>(); + + const plugin = useMemo(() => { + const decodedName = decodeURIComponent(name); + return pluginSettings.find(plugin => plugin.name === decodedName); + }, [pluginSettings, name]); + + if (!plugin) { + return ; + } + + return ; +} + +const ScrollableBox = (props: BoxProps) => ( + +); + +/** + * Represents the properties expected by the PluginSettingsDetails component. + * + * @property {Object} [config] - Optional configuration settings for the plugin. This is an object that contains current configuration of the plugin. + * @property {PluginInfo} plugin - Information about the plugin. + * @property {(data: { [key: string]: any }) => void} [onSave] - Optional callback function that is called when the settings are saved. The function receives an object representing the updated configuration settings for the plugin. + * @property {() => void} onDelete - Callback function that is called when the plugin is requested to be deleted. This function does not take any parameters and does not return anything. + * + * @see PluginInfo - Refer to the PluginInfo documentation for details on what this object should contain. + */ +export interface PluginSettingsDetailsPureProps { + config?: { [key: string]: any }; + plugin: PluginInfo; + onSave?: (data: { [key: string]: any }) => void; + onDelete: () => void; +} + +export function PluginSettingsDetailsPure(props: PluginSettingsDetailsPureProps) { + const { config, plugin, onSave, onDelete } = props; + const { t } = useTranslation(['translation']); + const [data, setData] = useState<{ [key: string]: any } | undefined>(config); + const [enableSaveButton, setEnableSaveButton] = useState(false); + const [openDeleteDialog, setOpenDeleteDialog] = useState(false); + + useEffect(() => { + if (!_.isEqual(config, data)) { + setEnableSaveButton(true); + } else { + setEnableSaveButton(false); + } + }, [data, config]); + + function onDataChange(data: { [key: string]: any }) { + setData(data); + } + + function handleSave() { + if (onSave && data) { + onSave(data); + } + } + + function handleDelete() { + setOpenDeleteDialog(true); + } + + function handleDeleteConfirm() { + onDelete(); + } + + function handleCancel() { + setData(config); + } + + let component; + if (isValidElement(plugin.settingsComponent)) { + component = plugin.settingsComponent; + } else if (typeof plugin.settingsComponent === 'function') { + const Comp = plugin.settingsComponent; + if (plugin.displaySettingsComponentWithSaveButton) { + component = ; + } else { + component = ; + } + } else { + component = null; + } + + return ( + <> + + {plugin.description} + + setOpenDeleteDialog(false)} + onConfirm={() => handleDeleteConfirm()} + /> + {component} + + + + + + {plugin.displaySettingsComponentWithSaveButton && ( + <> + + + + )} + + {helpers.isElectron() ? ( + + ) : null} + + + + ); +} diff --git a/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettingsDetails.stories.storyshot b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettingsDetails.stories.storyshot new file mode 100644 index 0000000000..f006433315 --- /dev/null +++ b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettingsDetails.stories.storyshot @@ -0,0 +1,256 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots Settings/PluginSettingsDetail With Auto Save 1`] = ` +
+ +
+
+
+
+

+ Example Plugin AutoSave +

+
+
+
+
+
+ This is an example plugin with auto-save enabled. +
+
+
+ +
+ + +
+
+
+
+
+
+
+
+ +
+
+
+`; + +exports[`Storyshots Settings/PluginSettingsDetail Without Auto Save 1`] = ` +
+ +
+
+
+
+

+ Example Plugin Normal +

+
+
+
+
+
+ This is an example plugin with normal save. +
+
+
+ +
+ + +
+
+
+
+
+
+
+
+ + +
+ +
+
+
+`; diff --git a/frontend/src/components/App/Settings/__snapshots__/Settings.stories.storyshot b/frontend/src/components/App/Settings/__snapshots__/Settings.stories.storyshot index 74442c3126..e89df77fa5 100644 --- a/frontend/src/components/App/Settings/__snapshots__/Settings.stories.storyshot +++ b/frontend/src/components/App/Settings/__snapshots__/Settings.stories.storyshot @@ -174,7 +174,7 @@ exports[`Storyshots Settings General 1`] = ` style="width: 100px;" >
@@ -240,16 +230,6 @@ exports[`Storyshots Settings/PluginSettingsDetail Without Auto Save 1`] = ` />
-
diff --git a/frontend/src/components/Sidebar/__snapshots__/Sidebar.stories.storyshot b/frontend/src/components/Sidebar/__snapshots__/Sidebar.stories.storyshot index f865169d37..45b87f071f 100644 --- a/frontend/src/components/Sidebar/__snapshots__/Sidebar.stories.storyshot +++ b/frontend/src/components/Sidebar/__snapshots__/Sidebar.stories.storyshot @@ -102,7 +102,31 @@ exports[`Storyshots Sidebar/Sidebar Home Sidebar Closed 1`] = ` >
@@ -274,7 +298,31 @@ exports[`Storyshots Sidebar/Sidebar Home Sidebar Open 1`] = ` > diff --git a/frontend/src/components/Sidebar/prepareRoutes.ts b/frontend/src/components/Sidebar/prepareRoutes.ts index 9af8f2c32d..53804a152a 100644 --- a/frontend/src/components/Sidebar/prepareRoutes.ts +++ b/frontend/src/components/Sidebar/prepareRoutes.ts @@ -41,7 +41,6 @@ function prepareRoutes( name: 'plugins', label: t('translation|Plugins'), url: '/settings/plugins', - hide: !helpers.isElectron(), }, ], }, diff --git a/frontend/src/lib/router.tsx b/frontend/src/lib/router.tsx index 24b7fb8cc2..279cff2fec 100644 --- a/frontend/src/lib/router.tsx +++ b/frontend/src/lib/router.tsx @@ -721,7 +721,6 @@ const defaultRoutes: { }, useClusterURL: false, noAuthRequired: true, - disabled: !helpers.isElectron(), component: () => , }, pluginDetails: {