diff --git a/backend/cmd/headlamp.go b/backend/cmd/headlamp.go index 23177c1b08..b7a994f2ba 100644 --- a/backend/cmd/headlamp.go +++ b/backend/cmd/headlamp.go @@ -250,6 +250,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 d96f683c43..00d2c25bb1 100644 --- a/backend/pkg/plugins/plugins.go +++ b/backend/pkg/plugins/plugins.go @@ -5,7 +5,9 @@ import ( "io/fs" "net/http" "os" + "path" "path/filepath" + "strings" "time" "github.com/fsnotify/fsnotify" @@ -228,3 +230,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") + } + }) + } +} diff --git a/docs/development/api/interfaces/plugin_registry.PluginSettingsDetailsProps.md b/docs/development/api/interfaces/plugin_registry.PluginSettingsDetailsProps.md new file mode 100644 index 0000000000..d782068c25 --- /dev/null +++ b/docs/development/api/interfaces/plugin_registry.PluginSettingsDetailsProps.md @@ -0,0 +1,18 @@ +--- +title: "Interface: PluginSettingsDetailsProps" +linkTitle: "PluginSettingsDetailsProps" +slug: "plugin_registry.PluginSettingsDetailsProps" +--- + +[plugin/registry](../modules/plugin_registry.md).PluginSettingsDetailsProps + +## Properties + +### onDataChange + +• `Optional` **onDataChange**: (`data`: { [`key`: `string`]: `any` }) => `void` +• `readonly` **data**: { [`key`: `string`]: `any` } + +#### Defined in +[plugin/pluginsSlice.ts](https://github.com/headlamp-k8s/headlamp/blob/main/frontend/src/plugin/pluginsSlice.ts#L7) + diff --git a/docs/development/api/modules/plugin_registry.md b/docs/development/api/modules/plugin_registry.md index e6efb40683..264fdd2259 100644 --- a/docs/development/api/modules/plugin_registry.md +++ b/docs/development/api/modules/plugin_registry.md @@ -96,6 +96,13 @@ ___ [components/DetailsViewSection/DetailsViewSection.tsx:9](https://github.com/headlamp-k8s/headlamp/blob/b0236780/frontend/src/components/DetailsViewSection/DetailsViewSection.tsx#L9) +___ +### PluginSettingsComponentType + +Ƭ **PluginSettingsComponentType**: `React.ComponentType`<[`PluginSettingsDetailsProps`](../interfaces/plugin_registry.PluginSettingsDetailsProps.md)\> \| `ReactElement` \| typeof `React.Component` \| ``null`` + +#### Defined in +[plugin/pluginsSlice.ts:24](https://github.com/headlamp-k8s/headlamp/blob/main/frontend/src/plugin/pluginsSlice.ts#L24) ___ ### sectionFunc @@ -661,3 +668,50 @@ registerSidebarEntryFilter(entry => (entry.name === 'workloads' ? null : entry)) #### Defined in [plugin/registry.tsx:231](https://github.com/headlamp-k8s/headlamp/blob/b0236780/frontend/src/plugin/registry.tsx#L231) + +___ +### registerPluginSettings + +▸ **registerPluginSettings**(`name`,`component`,`displaySaveButton`): `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 +); +``` + +#### Parameters + +| Name | Type | Description | +| :------ | :------ | :------ | +| `name` | `string` | The name of the plugin. | +| `component` | `PluginSettingsComponentType` | The component to be rendered in the plugin settings page. | +| `displaySaveButton` | `boolean` | Whether to display the save button. | \ No newline at end of file diff --git a/docs/development/plugins/functionality.md b/docs/development/plugins/functionality.md index cb6f7d5ef6..10e2987656 100644 --- a/docs/development/plugins/functionality.md +++ b/docs/development/plugins/functionality.md @@ -166,3 +166,12 @@ React to Headlamp events with [registerHeadlampEventCallback](../api/modules/plu - Example plugin shows [How to show snackbars for Headlamp events](https://github.com/kinvolk/headlamp/tree/main/plugins/examples/headlamp-events). - API reference for [registerHeadlampEventCallback](../api/modules/plugin_registry.md#registerheadlampeventcallback) + + +### Plugin Settings + +The plugins can have user configurable settings that can be used to change the behavior of the plugin. The plugin settings can be created using [registerPluginSettings](../api/modules/plugin_registry.md#registerpluginsettings). + +- Example plugin shows [How to create plugin settings and use them](https://github.com/kinvolk/headlamp/tree/main/plugins/examples/change-logo) + +![screenshot of the plugin settings](./images/plugin-settings.png) \ No newline at end of file diff --git a/docs/development/plugins/images/plugin-settings.png b/docs/development/plugins/images/plugin-settings.png new file mode 100644 index 0000000000..aea0c90848 Binary files /dev/null and b/docs/development/plugins/images/plugin-settings.png differ diff --git a/frontend/src/components/App/PluginSettings/PluginSettings.tsx b/frontend/src/components/App/PluginSettings/PluginSettings.tsx index 6c09a4a415..dfe2196b9a 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 { Link as HeadlampLink, 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,65 @@ 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 +227,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/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__/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 - + +
+
+
+
+

+ 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;" >
@@ -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/i18n/locales/de/translation.json b/frontend/src/i18n/locales/de/translation.json index 685a11a32f..c371af5cbd 100644 --- a/frontend/src/i18n/locales/de/translation.json +++ b/frontend/src/i18n/locales/de/translation.json @@ -36,7 +36,16 @@ "Date": "Datum", "Visible": "Sichtbar", "Plugins": "Plugins", + "Description": "Beschreibung", + "Origin": "", + "Unknown": "", + "Enabled": "", + "Disabled": "", + "Enable": "", "Save & Apply": "Speichern & Anwenden", + "Delete Plugin": "", + "Are you sure you want to delete this plugin?": "", + "Save": "", "Enter a value between {{ minRows }} and {{ maxRows }}.": "Geben Sie einen Wert zwischen {{ minRows }} und {{ maxRows }} ein.", "Custom row value": "Benutzerdefinierter Zeilenwert", "Apply": "Anwenden", @@ -333,7 +342,6 @@ "More": "Mehr", "Global Default": "Globale Voreinstellung", "Preemption Policy": "Preemtions-Policy", - "Description": "Beschreibung", "Current//context:replicas": "Aktuell", "Desired//context:replicas": "Gewünscht", "Used": "Genutzt", diff --git a/frontend/src/i18n/locales/en/translation.json b/frontend/src/i18n/locales/en/translation.json index 874e16a0da..66ee39fd5b 100644 --- a/frontend/src/i18n/locales/en/translation.json +++ b/frontend/src/i18n/locales/en/translation.json @@ -36,7 +36,16 @@ "Date": "Date", "Visible": "Visible", "Plugins": "Plugins", + "Description": "Description", + "Origin": "Origin", + "Unknown": "Unknown", + "Enabled": "Enabled", + "Disabled": "Disabled", + "Enable": "Enable", "Save & Apply": "Save & Apply", + "Delete Plugin": "Delete Plugin", + "Are you sure you want to delete this plugin?": "Are you sure you want to delete this plugin?", + "Save": "Save", "Enter a value between {{ minRows }} and {{ maxRows }}.": "Enter a value between {{ minRows }} and {{ maxRows }}.", "Custom row value": "Custom row value", "Apply": "Apply", @@ -333,7 +342,6 @@ "More": "More", "Global Default": "Global Default", "Preemption Policy": "Preemption Policy", - "Description": "Description", "Current//context:replicas": "Current", "Desired//context:replicas": "Desired", "Used": "Used", diff --git a/frontend/src/i18n/locales/es/translation.json b/frontend/src/i18n/locales/es/translation.json index 0593099a46..3d773cc31f 100644 --- a/frontend/src/i18n/locales/es/translation.json +++ b/frontend/src/i18n/locales/es/translation.json @@ -36,7 +36,16 @@ "Date": "Fecha", "Visible": "Visible", "Plugins": "Plugins", + "Description": "Descripción", + "Origin": "", + "Unknown": "", + "Enabled": "", + "Disabled": "", + "Enable": "", "Save & Apply": "Guardar & Aplicar", + "Delete Plugin": "", + "Are you sure you want to delete this plugin?": "", + "Save": "", "Enter a value between {{ minRows }} and {{ maxRows }}.": "Introduzca un valor entre {{ minRows }} y {{ maxRows }}.", "Custom row value": "Núm. de líneas personalizado", "Apply": "Aplicar", @@ -333,7 +342,6 @@ "More": "Más", "Global Default": "Por Defecto Global", "Preemption Policy": "Política de \"Preemption\"", - "Description": "Descripción", "Current//context:replicas": "Actuales", "Desired//context:replicas": "Deseadas", "Used": "Usado", diff --git a/frontend/src/i18n/locales/fr/translation.json b/frontend/src/i18n/locales/fr/translation.json index 0222459524..351ac0242d 100644 --- a/frontend/src/i18n/locales/fr/translation.json +++ b/frontend/src/i18n/locales/fr/translation.json @@ -36,7 +36,16 @@ "Date": "Date", "Visible": "Visible", "Plugins": "Plugins", + "Description": "Description", + "Origin": "", + "Unknown": "", + "Enabled": "", + "Disabled": "", + "Enable": "", "Save & Apply": "Sauvegarder et appliquer", + "Delete Plugin": "", + "Are you sure you want to delete this plugin?": "", + "Save": "", "Enter a value between {{ minRows }} and {{ maxRows }}.": "Entrez une valeur entre {{ minRows }} et {{ maxRows }}.", "Custom row value": "Valeur de ligne personnalisée", "Apply": "Appliquer", @@ -333,7 +342,6 @@ "More": "Plus de", "Global Default": "Défaut global", "Preemption Policy": "Politique de préemption", - "Description": "Description", "Current//context:replicas": "Actuels", "Desired//context:replicas": "Souhaités", "Used": "Utilisé", diff --git a/frontend/src/i18n/locales/pt/translation.json b/frontend/src/i18n/locales/pt/translation.json index 7e3db05fd5..af7f50f78f 100644 --- a/frontend/src/i18n/locales/pt/translation.json +++ b/frontend/src/i18n/locales/pt/translation.json @@ -36,7 +36,16 @@ "Date": "Data", "Visible": "Visível", "Plugins": "Plugins", + "Description": "Descrição", + "Origin": "", + "Unknown": "", + "Enabled": "", + "Disabled": "", + "Enable": "", "Save & Apply": "Guardar & Aplicar", + "Delete Plugin": "", + "Are you sure you want to delete this plugin?": "", + "Save": "", "Enter a value between {{ minRows }} and {{ maxRows }}.": "Introduza um valor entre {{ minRows }} e {{ maxRows }}.", "Custom row value": "Núm. de linhas personalizado", "Apply": "Aplicar", @@ -333,7 +342,6 @@ "More": "Mais", "Global Default": "Por Defeito Global", "Preemption Policy": "Política de \"Preemption\"", - "Description": "Descrição", "Current//context:replicas": "Actuais", "Desired//context:replicas": "Desejadas", "Used": "Usado", 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 + ); +} diff --git a/frontend/src/lib/router.tsx b/frontend/src/lib/router.tsx index 068523d8e8..279cff2fec 100644 --- a/frontend/src/lib/router.tsx +++ b/frontend/src/lib/router.tsx @@ -4,6 +4,7 @@ import AuthToken from '../components/account/Auth'; import Home from '../components/App/Home'; import NotificationList from '../components/App/Notifications/List'; import PluginSettings from '../components/App/PluginSettings'; +import PluginSettingsDetails from '../components/App/PluginSettings/PluginSettingsDetails'; import Settings from '../components/App/Settings'; import SettingsCluster from '../components/App/Settings/SettingsCluster'; import SettingsClusters from '../components/App/Settings/SettingsClusters'; @@ -720,9 +721,20 @@ const defaultRoutes: { }, useClusterURL: false, noAuthRequired: true, - disabled: !helpers.isElectron(), component: () => , }, + pluginDetails: { + path: '/settings/plugins/:name', + exact: true, + name: 'Plugin Details', + sidebar: { + item: 'plugins', + sidebar: DefaultSidebars.HOME, + }, + useClusterURL: false, + noAuthRequired: true, + component: () => , + }, portforwards: { path: '/portforwards', exact: true, 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/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..d4494e9389 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. @@ -16,6 +42,10 @@ export type PluginInfo = { * @see https://docs.npmjs.com/cli/v9/configuring-npm/package-json?v=true#description */ description: string; + /** + * origin is the source of the plugin. + */ + origin?: string; /** * homepage is the URL link address for the plugin defined from the package.json */ @@ -35,6 +65,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 +109,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/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; diff --git a/plugins/examples/change-logo/README.md b/plugins/examples/change-logo/README.md index e371695751..4cb31f2f24 100644 --- a/plugins/examples/change-logo/README.md +++ b/plugins/examples/change-logo/README.md @@ -1,6 +1,6 @@ # Example Plugin: Changing The Logo -This shows you how to change the Headlamp logo. +This shows you how to change the Headlamp logo and make it user configurable. ![screenshot of the logo being changed](../../../docs/development/plugins/images/change-logo.png) @@ -18,8 +18,10 @@ npm start - For an image, at a minimum you will need to provide a small logo and a big one. - There are two example svg images which you can check for sizes. - Click the logo to see the two states the logo can be in (small and big). +- The logo can be configured by the user in the settings page of the plugin. The main code for the example plugin is in [src/index.tsx](src/index.tsx). +The code for the plugin settings is in [src/settings.tsx](src/settings.tsx). See the API documentation for: diff --git a/plugins/examples/change-logo/src/index.tsx b/plugins/examples/change-logo/src/index.tsx index 28ba6544a3..02ca970e9c 100644 --- a/plugins/examples/change-logo/src/index.tsx +++ b/plugins/examples/change-logo/src/index.tsx @@ -3,11 +3,15 @@ // import { registerAppLogo } from '@kinvolk/headlamp-plugin/lib'; // registerAppLogo(() =>

My Logo

); -import { AppLogoProps, registerAppLogo } from '@kinvolk/headlamp-plugin/lib'; -import { SvgIcon } from '@mui/material'; +import { + AppLogoProps, + registerAppLogo, + registerPluginSettings, +} from '@kinvolk/headlamp-plugin/lib'; +import { Avatar, SvgIcon } from '@mui/material'; import LogoWithTextLight from './icon-large-light.svg'; import LogoLight from './icon-small-light.svg'; - +import Settings, { store } from './settings'; /** * A simple logo using two different SVG files. * One for the small logo (used in mobile view), and a larger one used in desktop view. @@ -18,7 +22,12 @@ import LogoLight from './icon-small-light.svg'; function SimpleLogo(props: AppLogoProps) { const { logoType, className } = props; - return ( + const useConf = store.useConfig(); + const config = useConf(); + + return config?.url ? ( + + ) : ( { + const newValue = event.target.value; + setValue(newValue); + + if (timer) { + clearTimeout(timer); + } + + const newTimer = setTimeout(() => onSave(newValue), delay); + setTimer(newTimer); + }; + + useEffect(() => { + // Cleanup on unmount + return () => { + if (timer) { + clearTimeout(timer); + } + }; + }, [timer]); + + return ( + + ); +} + +interface pluginConfig { + url: string; +} + +export const store = new ConfigStore('change-logo'); + +/** + * Settings component for managing plugin configuration details. + * It allows users to update specific configuration properties, such as the logo URL, + * and automatically saves these updates to a persistent store. + * + * @returns {JSX.Element} The rendered settings component with configuration options. + */ +export default function Settings() { + // Retrieve initial configuration from the store + const config = store.get(); + // State to manage the current configuration within the component + const [currentConfig, setCurrentConfig] = useState(config); + + /** + * Handles saving the updated configuration value to the store. + * It updates the specified configuration property and refreshes the local component state + * to reflect the latest configuration. + * + * @param {string} value - The new value for the configuration property to be updated. + */ + function handleSave(value) { + const updatedConfig = { url: value }; + // Save the updated configuration to the store + store.set(updatedConfig); + // Update the component state to reflect the new configuration + setCurrentConfig(store.get()); + } + + // Define rows for the settings table, including the AutoSaveInput component for the logo URL + const settingsRows = [ + { + name: 'Logo URL', + value: ( + + ), + }, + ]; + + // Render the settings component + return ( + + + + ); +} diff --git a/plugins/examples/pod-counter/src/Message.tsx b/plugins/examples/pod-counter/src/Message.tsx index e0fce2bd6e..f2073f0cdc 100644 --- a/plugins/examples/pod-counter/src/Message.tsx +++ b/plugins/examples/pod-counter/src/Message.tsx @@ -1,3 +1,4 @@ +import { ConfigStore } from '@kinvolk/headlamp-plugin/lib'; import { Typography } from '@mui/material'; export interface MessageProps { @@ -17,9 +18,13 @@ export interface MessageProps { * */ export default function Message({ msg, error }: MessageProps) { + const config = new ConfigStore<{ errorMessage?: string }>('@kinvolk/headlamp-pod-counter'); + const useConf = config.useConfig(); + const conf = useConf(); + return ( - {!error ? `# Pods: ${msg}` : 'Uh, pods!?'} + {!error ? `# Pods: ${msg}` : conf?.errorMessage ? conf?.errorMessage : 'Uh, pods!?'} ); } diff --git a/plugins/examples/pod-counter/src/index.tsx b/plugins/examples/pod-counter/src/index.tsx index 8f1e146ac2..abbd8e08e5 100644 --- a/plugins/examples/pod-counter/src/index.tsx +++ b/plugins/examples/pod-counter/src/index.tsx @@ -3,7 +3,11 @@ import { DefaultAppBarAction, K8s, registerAppBarAction, + registerPluginSettings, } from '@kinvolk/headlamp-plugin/lib'; +import { NameValueTable } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; +import Box from '@mui/material/Box'; +import TextField from '@mui/material/TextField'; import Message from './Message'; function PodCounter() { @@ -30,3 +34,49 @@ registerAppBarAction(function reorderNotifications({ actions }: AppBarActionsPro return newActions; }); + +/** + * A component for displaying and editing plugin settings, specifically for customizing error messages. + * It renders a text input field that allows users to specify a custom error message. + * This message is intended to be displayed when a specific error condition occurs (e.g., pod count cannot be retrieved). + * + * @param {PluginSettingsDetailsProps} props - Properties passed to the Settings component. + * @param {Object} props.data - The current configuration data for the plugin, including the current error message. + * @param {function(Object): void} props.onDataChange - Callback function to handle changes to the data, specifically the error message. + */ +function Settings(props) { + const { data, onDataChange } = props; + + /** + * Handles changes to the error message input field by invoking the onDataChange callback + * with the new error message. + * + * @param {React.ChangeEvent} event - The change event from the input field. + */ + const handleChange = event => { + onDataChange({ errorMessage: event.target.value }); + }; + + const settingsRows = [ + { + name: 'Custom Error Message', + value: ( + + ), + }, + ]; + + return ( + + + + ); +} + +registerPluginSettings('@kinvolk/headlamp-pod-counter', Settings, true); 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, +};