diff --git a/app/electron/main.ts b/app/electron/main.ts index 672a678ca2c..1ec256b2e08 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -45,6 +45,7 @@ import { addToPath, ArtifactHubHeadlampPkg, defaultPluginsDir, + defaultUserPluginsDir, getMatchingExtraFiles, getPluginBinDirectories, PluginManager, @@ -422,6 +423,9 @@ class PluginManagerEventListeners { /** * Handles the list event. * + * Lists plugins from all three directories (shipped, user-installed, development) + * and returns a combined list with their locations. + * * @method * @name handleList * @param {Electron.IpcMainEvent} event - The IPC Main Event. @@ -430,9 +434,84 @@ class PluginManagerEventListeners { */ private handleList(event: Electron.IpcMainEvent, eventData: Action) { const { identifier, destinationFolder } = eventData; - PluginManager.list(destinationFolder, progress => { - event.sender.send('plugin-manager', JSON.stringify({ identifier: identifier, ...progress })); - }); + + // If a specific folder is requested, list only from that folder + if (destinationFolder) { + PluginManager.list(destinationFolder, progress => { + event.sender.send( + 'plugin-manager', + JSON.stringify({ identifier: identifier, ...progress }) + ); + }); + return; + } + + // Otherwise, list from all three directories + try { + const allPlugins: any[] = []; + + // List from shipped plugins (.plugins) + const shippedDir = path.join(__dirname, '.plugins'); + try { + const shippedPlugins = PluginManager.list(shippedDir); + if (shippedPlugins) { + allPlugins.push(...shippedPlugins); + } + } catch (error: any) { + // Only ignore if directory doesn't exist, log other errors + if (error?.code !== 'ENOENT') { + console.error('Error listing shipped plugins:', error); + } + } + + // List from user-installed plugins (user-plugins) + const userDir = defaultUserPluginsDir(); + try { + const userPlugins = PluginManager.list(userDir); + if (userPlugins) { + allPlugins.push(...userPlugins); + } + } catch (error: any) { + // Only ignore if directory doesn't exist, log other errors + if (error?.code !== 'ENOENT') { + console.error('Error listing user plugins:', error); + } + } + + // List from development plugins (plugins) + const devDir = defaultPluginsDir(); + try { + const devPlugins = PluginManager.list(devDir); + if (devPlugins) { + allPlugins.push(...devPlugins); + } + } catch (error: any) { + // Only ignore if directory doesn't exist, log other errors + if (error?.code !== 'ENOENT') { + console.error('Error listing development plugins:', error); + } + } + + // Send combined results + event.sender.send( + 'plugin-manager', + JSON.stringify({ + identifier: identifier, + type: 'success', + message: 'Plugins Listed', + data: allPlugins, + }) + ); + } catch (error) { + event.sender.send( + 'plugin-manager', + JSON.stringify({ + identifier: identifier, + type: 'error', + message: error instanceof Error ? error.message : String(error), + }) + ); + } } /** @@ -1590,6 +1669,31 @@ function startElecron() { new PluginManagerEventListeners().setupEventHandlers(); + // Handle opening plugin folder in file explorer + ipcMain.on( + 'open-plugin-folder', + ( + event: IpcMainEvent, + pluginInfo: { folderName: string; type: 'development' | 'user' | 'shipped' } + ) => { + let folderPath: string | null = null; + + if (pluginInfo.type === 'user') { + folderPath = path.join(defaultUserPluginsDir(), pluginInfo.folderName); + } else if (pluginInfo.type === 'development') { + folderPath = path.join(defaultPluginsDir(), pluginInfo.folderName); + } else if (pluginInfo.type === 'shipped') { + folderPath = path.join(process.resourcesPath, '.plugins', pluginInfo.folderName); + } + + if (folderPath) { + shell.openPath(folderPath).catch((err: Error) => { + console.error('Failed to open plugin folder:', err); + }); + } + } + ); + // Also add bundled plugin bin directories to PATH const bundledPlugins = path.join(process.resourcesPath, '.plugins'); const bundledPluginBinDirs = getPluginBinDirectories(bundledPlugins); diff --git a/app/electron/plugin-management.ts b/app/electron/plugin-management.ts index 539661a0459..5c7f7d807cf 100644 --- a/app/electron/plugin-management.ts +++ b/app/electron/plugin-management.ts @@ -141,7 +141,7 @@ export class PluginManager { /** * Installs a plugin from the specified URL. * @param {string} URL - The URL of the plugin to install. - * @param {string} [destinationFolder=defaultPluginsDir()] - The folder where the plugin will be installed. + * @param {string} [destinationFolder=defaultUserPluginsDir()] - The folder where the plugin will be installed. * @param {string} [headlampVersion=""] - The version of Headlamp for compatibility checking. * @param {function} [progressCallback=null] - Optional callback for progress updates. * @param {AbortSignal} [signal=null] - Optional AbortSignal for cancellation. @@ -149,7 +149,7 @@ export class PluginManager { */ static async install( URL: string, - destinationFolder: string = defaultPluginsDir(), + destinationFolder: string = defaultUserPluginsDir(), headlampVersion: string = '', progressCallback: null | ProgressCallback = null, signal: AbortSignal | null = null @@ -178,7 +178,7 @@ export class PluginManager { /** * Installs a plugin from the given plugin data. * @param {PluginData} pluginData - The plugin data from which to install the plugin. - * @param {string} [destinationFolder=defaultPluginsDir()] - The folder where the plugin will be installed. + * @param {string} [destinationFolder=defaultUserPluginsDir()] - The folder where the plugin will be installed. * @param {string} [headlampVersion=""] - The version of Headlamp for compatibility checking. * @param {function} [progressCallback=null] - Optional callback for progress updates. * @param {AbortSignal} [signal=null] - Optional AbortSignal for cancellation. @@ -186,7 +186,7 @@ export class PluginManager { */ static async installFromPluginPkg( pluginData: ArtifactHubHeadlampPkg, - destinationFolder = defaultPluginsDir(), + destinationFolder = defaultUserPluginsDir(), headlampVersion = '', progressCallback: null | ProgressCallback = null, signal: AbortSignal | null = null @@ -230,7 +230,7 @@ export class PluginManager { /** * Updates an installed plugin to the latest version. * @param {string} pluginName - The name of the plugin to update. - * @param {string} [destinationFolder=defaultPluginsDir()] - The folder where the plugin is installed. + * @param {string} [destinationFolder=defaultUserPluginsDir()] - The folder where the plugin is installed. * @param {string} [headlampVersion=""] - The version of Headlamp for compatibility checking. * @param {null | ProgressCallback} [progressCallback=null] - Optional callback for progress updates. * @param {AbortSignal} [signal=null] - Optional AbortSignal for cancellation. @@ -238,7 +238,7 @@ export class PluginManager { */ static async update( pluginName: string, - destinationFolder: string = defaultPluginsDir(), + destinationFolder: string = defaultUserPluginsDir(), headlampVersion: string = '', progressCallback: null | ProgressCallback = null, signal: AbortSignal | null = null @@ -1020,6 +1020,20 @@ export function defaultPluginsDir() { return path.join(configDir, 'plugins'); } +/** + * Returns the default directory where user-installed plugins are stored. + * If the data path exists, it is used as the base directory. + * Otherwise, the config path is used as the base directory. + * The 'user-plugins' subdirectory of the base directory is returned. + * + * @returns {string} The path to the default user-plugins directory. + */ +export function defaultUserPluginsDir() { + const paths = envPaths('Headlamp', { suffix: '' }); + const configDir = fs.existsSync(paths.data) ? paths.data : paths.config; + return path.join(configDir, 'user-plugins'); +} + /** * Checks if a given folder is a valid plugin bin folder. * diff --git a/app/electron/preload.ts b/app/electron/preload.ts index 4a32eb276a7..5e0f499541e 100644 --- a/app/electron/preload.ts +++ b/app/electron/preload.ts @@ -30,6 +30,7 @@ contextBridge.exposeInMainWorld('desktopApi', { 'plugin-manager', 'request-backend-token', 'request-plugin-permission-secrets', + 'open-plugin-folder', 'request-backend-port', ]; if (validChannels.includes(channel)) { diff --git a/backend/cmd/headlamp.go b/backend/cmd/headlamp.go index 4bda7d1ce67..f58e3558064 100644 --- a/backend/cmd/headlamp.go +++ b/backend/cmd/headlamp.go @@ -255,7 +255,7 @@ func defaultHeadlampKubeConfigFile() (string, error) { // addPluginRoutes adds plugin routes to a router. // It serves plugin list base paths as json at "plugins". -// It serves plugin static files at "plugins/" and "static-plugins/". +// It serves plugin static files at "plugins/", "user-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. @@ -266,7 +266,7 @@ func addPluginRoutes(config *HeadlampConfig, r *mux.Router) { addPluginListRoute(config, r) - // Serve plugins + // Serve development plugins pluginHandler := http.StripPrefix(config.BaseURL+"/plugins/", http.FileServer(http.Dir(config.PluginDir))) // If we're running locally, then do not cache the plugins. This ensures that reloading them (development, // update) will actually get the new content. @@ -276,6 +276,18 @@ func addPluginRoutes(config *HeadlampConfig, r *mux.Router) { r.PathPrefix("/plugins/").Handler(pluginHandler) + // Serve user-installed plugins + if config.UserPluginDir != "" { + userPluginsHandler := http.StripPrefix(config.BaseURL+"/user-plugins/", + http.FileServer(http.Dir(config.UserPluginDir))) + if !config.UseInCluster { + userPluginsHandler = serveWithNoCacheHeader(userPluginsHandler) + } + + r.PathPrefix("/user-plugins/").Handler(userPluginsHandler) + } + + // Serve shipped/static plugins if config.StaticPluginDir != "" { staticPluginsHandler := http.StripPrefix(config.BaseURL+"/static-plugins/", http.FileServer(http.Dir(config.StaticPluginDir))) @@ -291,6 +303,9 @@ func addPluginDeleteRoute(config *HeadlampConfig, r *mux.Router) { var span trace.Span pluginName := mux.Vars(r)["name"] + // Get plugin type from query parameter (optional) + pluginType := r.URL.Query().Get("type") + // Start tracing for deletePlugin. if config.Telemetry != nil { _, span = telemetry.CreateSpan(ctx, r, "plugins", "deletePlugin", @@ -313,12 +328,12 @@ func addPluginDeleteRoute(config *HeadlampConfig, r *mux.Router) { return } - err := plugins.Delete(config.PluginDir, pluginName) + err := plugins.Delete(config.UserPluginDir, config.PluginDir, pluginName, pluginType) if err != nil { config.telemetryHandler.RecordError(span, err, "Failed to delete plugin") logger.Log(logger.LevelError, nil, err, "Error deleting plugin: "+pluginName) - http.Error(w, "Error deleting plugin", http.StatusInternalServerError) + http.Error(w, err.Error(), http.StatusInternalServerError) return } logger.Log(logger.LevelInfo, nil, nil, "Plugin deleted successfully: "+pluginName) @@ -351,13 +366,13 @@ func addPluginListRoute(config *HeadlampConfig, r *mux.Router) { w.Header().Set("Content-Type", "application/json") pluginsList, err := config.cache.Get(context.Background(), plugins.PluginListKey) if err != nil && err == cache.ErrNotFound { - pluginsList = []string{} + pluginsList = []plugins.PluginMetadata{} if config.Telemetry != nil { span.SetAttributes(attribute.Int("plugins.count", 0)) } } else if config.Telemetry != nil && pluginsList != nil { - if list, ok := pluginsList.([]string); ok { + if list, ok := pluginsList.([]plugins.PluginMetadata); ok { span.SetAttributes(attribute.Int("plugins.count", len(list))) } } @@ -386,6 +401,7 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler { logger.Log(logger.LevelInfo, nil, nil, "Listen address: "+fmt.Sprintf("%s:%d", config.ListenAddr, config.Port)) logger.Log(logger.LevelInfo, nil, nil, "Kubeconfig path: "+kubeConfigPath) logger.Log(logger.LevelInfo, nil, nil, "Static plugin dir: "+config.StaticPluginDir) + logger.Log(logger.LevelInfo, nil, nil, "User plugins dir: "+config.UserPluginDir) logger.Log(logger.LevelInfo, nil, nil, "Plugins dir: "+config.PluginDir) logger.Log(logger.LevelInfo, nil, nil, "Dynamic clusters support: "+fmt.Sprint(config.EnableDynamicClusters)) logger.Log(logger.LevelInfo, nil, nil, "Helm support: "+fmt.Sprint(config.EnableHelm)) @@ -400,7 +416,7 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler { logger.Log(logger.LevelInfo, nil, nil, "Use In Cluster: "+fmt.Sprint(config.UseInCluster)) logger.Log(logger.LevelInfo, nil, nil, "Watch Plugins Changes: "+fmt.Sprint(config.WatchPluginsChanges)) - plugins.PopulatePluginsCache(config.StaticPluginDir, config.PluginDir, config.cache) + plugins.PopulatePluginsCache(config.StaticPluginDir, config.UserPluginDir, config.PluginDir, config.cache) skipFunc := kubeconfig.SkipKubeContextInCommaSeparatedString(config.SkippedKubeContexts) @@ -408,7 +424,26 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler { // in-cluster mode is unlikely to want reloading plugins. pluginEventChan := make(chan string) go plugins.Watch(config.PluginDir, pluginEventChan) - go plugins.HandlePluginEvents(config.StaticPluginDir, config.PluginDir, pluginEventChan, config.cache) + + // Watch user-plugins directory for catalog-installed plugins + if config.UserPluginDir != "" { + userPluginEventChan := make(chan string) + go plugins.Watch(config.UserPluginDir, userPluginEventChan) + // Merge both event channels into one + go func() { + for event := range userPluginEventChan { + pluginEventChan <- event + } + }() + } + + go plugins.HandlePluginEvents( + config.StaticPluginDir, + config.UserPluginDir, + config.PluginDir, + pluginEventChan, + config.cache, + ) // in-cluster mode is unlikely to want reloading kubeconfig. go kubeconfig.LoadAndWatchFiles(config.KubeConfigStore, kubeConfigPath, kubeconfig.KubeConfig, skipFunc) } diff --git a/backend/cmd/headlamp_test.go b/backend/cmd/headlamp_test.go index 86a0b7ea5c3..c67563d74b8 100644 --- a/backend/cmd/headlamp_test.go +++ b/backend/cmd/headlamp_test.go @@ -526,8 +526,18 @@ func TestDeletePlugin(t *testing.T) { defer os.RemoveAll(tempDir) - // create plugin - pluginDir := tempDir + "/test-plugin" + // create user-plugins dir + userPluginDir := tempDir + "/user-plugins" + err = os.Mkdir(userPluginDir, 0o755) + require.NoError(t, err) + + // create dev plugins dir + devPluginDir := tempDir + "/plugins" + err = os.Mkdir(devPluginDir, 0o755) + require.NoError(t, err) + + // create plugin in dev dir + pluginDir := devPluginDir + "/test-plugin" err = os.Mkdir(pluginDir, 0o755) require.NoError(t, err) @@ -543,7 +553,8 @@ func TestDeletePlugin(t *testing.T) { HeadlampCFG: &headlampconfig.HeadlampCFG{ UseInCluster: false, KubeConfigPath: config.GetDefaultKubeConfigPath(), - PluginDir: tempDir, + PluginDir: devPluginDir, + UserPluginDir: userPluginDir, KubeConfigStore: kubeConfigStore, }, cache: cache, diff --git a/backend/cmd/server.go b/backend/cmd/server.go index 73b434746e6..d1f00933768 100644 --- a/backend/cmd/server.go +++ b/backend/cmd/server.go @@ -74,6 +74,7 @@ func buildHeadlampCFG(conf *config.Config, kubeConfigStore kubeconfig.ContextSto StaticDir: conf.StaticDir, Insecure: conf.InsecureSsl, PluginDir: conf.PluginsDir, + UserPluginDir: conf.UserPluginsDir, EnableHelm: conf.EnableHelm, EnableDynamicClusters: conf.EnableDynamicClusters, WatchPluginsChanges: conf.WatchPluginsChanges, @@ -237,7 +238,7 @@ func runListPlugins() { os.Exit(1) } - if err := plugins.ListPlugins(conf.StaticDir, conf.PluginsDir); err != nil { + if err := plugins.ListPlugins(conf.StaticDir, conf.UserPluginsDir, conf.PluginsDir); err != nil { logger.Log(logger.LevelError, nil, err, "listing plugins") } } diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index a7092101349..9651118de30 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -18,7 +18,10 @@ import ( "github.com/kubernetes-sigs/headlamp/backend/pkg/logger" ) -const defaultPort = 4466 +const ( + defaultPort = 4466 + osWindows = "windows" +) const ( DefaultMeUsernamePath = "preferred_username,upn,username,name" @@ -42,6 +45,7 @@ type Config struct { SkippedKubeContexts string `koanf:"skipped-kube-contexts"` StaticDir string `koanf:"html-static-dir"` PluginsDir string `koanf:"plugins-dir"` + UserPluginsDir string `koanf:"user-plugins-dir"` BaseURL string `koanf:"base-url"` ProxyURLs string `koanf:"proxy-urls"` OidcClientID string `koanf:"oidc-client-id"` @@ -77,7 +81,7 @@ type Config struct { func (c *Config) Validate() error { if !c.InCluster && (c.OidcClientID != "" || c.OidcClientSecret != "" || c.OidcIdpIssuerURL != "" || c.OidcValidatorClientID != "" || c.OidcValidatorIdpIssuerURL != "") { - return errors.New(`oidc-client-id, oidc-client-secret, oidc-idp-issuer-url, oidc-validator-client-id, + return errors.New(`oidc-client-id, oidc-client-secret, oidc-idp-issuer-url, oidc-validator-client-id, oidc-validator-idp-issuer-url, flags are only meant to be used in inCluster mode`) } @@ -332,7 +336,7 @@ func MakeHeadlampKubeConfigsDir() (string, error) { if err == nil { kubeConfigDir := filepath.Join(userConfigDir, "Headlamp", "kubeconfigs") - if runtime.GOOS == "windows" { + if runtime.GOOS == osWindows { // golang is wrong for config folder on windows. // This matches env-paths and headlamp-plugin. kubeConfigDir = filepath.Join(userConfigDir, "Headlamp", "Config", "kubeconfigs") @@ -390,6 +394,7 @@ func addGeneralFlags(f *flag.FlagSet) { f.String("skipped-kube-contexts", "", "Context name which should be ignored in kubeconfig file") f.String("html-static-dir", "", "Static HTML directory to serve") f.String("plugins-dir", defaultPluginDir(), "Specify the plugins directory to build the backend with") + f.String("user-plugins-dir", defaultUserPluginDir(), "Specify the user-installed plugins directory") f.String("base-url", "", "Base URL path. eg. /headlamp") f.String("listen-addr", "", "Address to listen on; default is empty, which means listening to any address") f.Uint("port", defaultPort, "Port to listen from") @@ -453,7 +458,7 @@ func defaultPluginDir() string { } pluginsConfigDir := filepath.Join(userConfigDir, "Headlamp", "plugins") - if runtime.GOOS == "windows" { + if runtime.GOOS == osWindows { // golang is wrong for config folder on windows. // This matches env-paths and headlamp-plugin. pluginsConfigDir = filepath.Join(userConfigDir, "Headlamp", "Config", "plugins") @@ -471,6 +476,40 @@ func defaultPluginDir() string { return pluginsConfigDir } +// Gets the default user-plugins-dir depending on platform. +func defaultUserPluginDir() string { + // This is the folder we use for the default user-plugin-dir: + // - ~/.config/Headlamp/user-plugins exists or it can be made + // Windows: %APPDATA%\Headlamp\Config\user-plugins + // (for example, C:\Users\USERNAME\AppData\Roaming\Headlamp\Config\user-plugins) + // https://www.npmjs.com/package/env-paths + // https://pkg.go.dev/os#UserConfigDir + userConfigDir, err := os.UserConfigDir() + if err != nil { + logger.Log(logger.LevelError, nil, err, "getting user config dir") + + return "" + } + + userPluginsConfigDir := filepath.Join(userConfigDir, "Headlamp", "user-plugins") + if runtime.GOOS == osWindows { + // golang is wrong for config folder on windows. + // This matches env-paths and headlamp-plugin. + userPluginsConfigDir = filepath.Join(userConfigDir, "Headlamp", "Config", "user-plugins") + } + + fileMode := 0o755 + + err = os.MkdirAll(userPluginsConfigDir, fs.FileMode(fileMode)) + if err != nil { + logger.Log(logger.LevelError, nil, err, "creating user-plugins directory") + + return "" + } + + return userPluginsConfigDir +} + func GetDefaultKubeConfigPath() string { user, err := user.Current() if err != nil { diff --git a/backend/pkg/headlampconfig/headlampConfig.go b/backend/pkg/headlampconfig/headlampConfig.go index fd887d67b60..c689526d9db 100644 --- a/backend/pkg/headlampconfig/headlampConfig.go +++ b/backend/pkg/headlampconfig/headlampConfig.go @@ -19,6 +19,7 @@ type HeadlampCFG struct { SkippedKubeContexts string StaticDir string PluginDir string + UserPluginDir string StaticPluginDir string KubeConfigStore kubeconfig.ContextStore Telemetry *telemetry.Telemetry diff --git a/backend/pkg/plugins/plugins.go b/backend/pkg/plugins/plugins.go index ee78e069ad9..ebd1208231e 100644 --- a/backend/pkg/plugins/plugins.go +++ b/backend/pkg/plugins/plugins.go @@ -24,7 +24,6 @@ import ( "io/fs" "net/http" "os" - "path" "path/filepath" "strings" "time" @@ -42,6 +41,22 @@ const ( subFolderWatchInterval = 5 * time.Second ) +// PluginMetadata represents metadata about a plugin including its source type. +type PluginMetadata struct { + // Path is the URL path to access the plugin + Path string `json:"path"` + // Type indicates where the plugin comes from: "development", "user", or "shipped" + Type string `json:"type"` + // Name is the plugin's folder name + Name string `json:"name"` +} + +const ( + PluginTypeDevelopment = "development" + PluginTypeUser = "user" + PluginTypeShipped = "shipped" +) + // Watch watches the given path for changes and sends the events to the notify channel. func Watch(path string, notify chan<- string) { watcher, err := fsnotify.NewWatcher() @@ -98,45 +113,132 @@ func periodicallyWatchSubfolders(watcher *fsnotify.Watcher, path string, interva } } -// generateSeparatePluginPaths takes the staticPluginDir and pluginDir and returns separate lists of plugin paths. -func generateSeparatePluginPaths(staticPluginDir, pluginDir string) ([]string, []string, error) { +// generateSeparatePluginPaths takes the staticPluginDir, userPluginDir, +// and pluginDir (dev) and returns separate lists of plugin paths. +func generateSeparatePluginPaths( + staticPluginDir, userPluginDir, pluginDir string, +) ([]string, []string, []string, error) { var pluginListURLStatic []string + var pluginListURLUser []string + if staticPluginDir != "" { var err error pluginListURLStatic, err = pluginBasePathListForDir(staticPluginDir, "static-plugins") if err != nil { - return nil, nil, err + return nil, nil, nil, err + } + } + + if userPluginDir != "" { + var err error + + pluginListURLUser, err = pluginBasePathListForDir(userPluginDir, "user-plugins") + if err != nil { + return nil, nil, nil, err } } pluginListURL, err := pluginBasePathListForDir(pluginDir, "plugins") if err != nil { - return nil, nil, err + return nil, nil, nil, err } - return pluginListURLStatic, pluginListURL, nil + return pluginListURLStatic, pluginListURLUser, pluginListURL, nil } -// GeneratePluginPaths generates a concatenated list of plugin paths from the staticPluginDir and pluginDir. -func GeneratePluginPaths(staticPluginDir, pluginDir string) ([]string, error) { - pluginListURLStatic, pluginListURL, err := generateSeparatePluginPaths(staticPluginDir, pluginDir) +// GeneratePluginPaths generates a list of all plugin paths from all directories. +// Returns all plugins with their type (development, user, or shipped). +// The frontend is responsible for implementing priority-based loading and handling duplicates. +// +// Migration: Plugins in the development directory that have isManagedByHeadlampPlugin=true +// in their package.json are treated as "user" plugins instead, as they were installed via +// the catalog before the user-plugins directory was introduced. +func GeneratePluginPaths( + staticPluginDir, userPluginDir, pluginDir string, +) ([]PluginMetadata, error) { + pluginListURLStatic, pluginListURLUser, pluginListURLDev, err := generateSeparatePluginPaths( + staticPluginDir, userPluginDir, pluginDir, + ) if err != nil { return nil, err } - // Concatenate the static and user plugin lists. - if pluginListURLStatic != nil { - pluginListURL = append(pluginListURLStatic, pluginListURL...) + pluginList := make([]PluginMetadata, 0) + + // Add shipped plugins (lowest priority) + for _, pluginURL := range pluginListURLStatic { + pluginName := filepath.Base(pluginURL) + pluginList = append(pluginList, PluginMetadata{ + Path: pluginURL, + Type: "shipped", + Name: pluginName, + }) } - return pluginListURL, nil + // Add user-installed plugins (medium priority) + for _, pluginURL := range pluginListURLUser { + pluginName := filepath.Base(pluginURL) + pluginList = append(pluginList, PluginMetadata{ + Path: pluginURL, + Type: "user", + Name: pluginName, + }) + } + + // Add development plugins (highest priority) + // However, if a plugin in the development directory was installed via the catalog + // (has isManagedByHeadlampPlugin=true), treat it as a user plugin instead. + // This handles migration from older versions where catalog plugins were installed to plugins/ directory. + for _, pluginURL := range pluginListURLDev { + pluginName := filepath.Base(pluginURL) + pluginType := PluginTypeDevelopment + + // Check if this is a catalog-installed plugin that needs migration + if isCatalogInstalledPlugin(pluginDir, pluginName) { + pluginType = PluginTypeUser + + logger.Log(logger.LevelInfo, map[string]string{ + "plugin": pluginName, + "path": pluginURL, + }, nil, "Treating catalog-installed plugin in development directory as user plugin") + } + + pluginList = append(pluginList, PluginMetadata{ + Path: pluginURL, + Type: pluginType, + Name: pluginName, + }) + } + + return pluginList, nil } -// ListPlugins lists the plugins in the static and user-added plugin directories. -func ListPlugins(staticPluginDir, pluginDir string) error { - staticPlugins, userPlugins, err := generateSeparatePluginPaths(staticPluginDir, pluginDir) +// isCatalogInstalledPlugin checks if a plugin was installed via the catalog. +// Catalog-installed plugins have isManagedByHeadlampPlugin: true in their package.json. +func isCatalogInstalledPlugin(pluginDir, pluginName string) bool { + packageJSONPath := filepath.Join(pluginDir, pluginName, "package.json") + + content, err := os.ReadFile(packageJSONPath) + if err != nil { + return false + } + + var packageData struct { + IsManagedByHeadlampPlugin bool `json:"isManagedByHeadlampPlugin"` + } + + if err := json.Unmarshal(content, &packageData); err != nil { + return false + } + + return packageData.IsManagedByHeadlampPlugin +} + +// ListPlugins lists the plugins in the static, user-installed, and development plugin directories. +func ListPlugins(staticPluginDir, userPluginDir, pluginDir string) error { + staticPlugins, userPlugins, devPlugins, err := generateSeparatePluginPaths(staticPluginDir, userPluginDir, pluginDir) if err != nil { logger.Log(logger.LevelError, nil, err, "listing plugins") return fmt.Errorf("listing plugins: %w", err) @@ -164,24 +266,35 @@ func ListPlugins(staticPluginDir, pluginDir string) error { } if len(staticPlugins) > 0 { - fmt.Printf("Static Plugins (%s):\n", staticPluginDir) + fmt.Printf("Shipped Plugins (%s):\n", staticPluginDir) for _, plugin := range staticPlugins { fmt.Println(" -", getPluginName(plugin)) } } else { - fmt.Println("No static plugins found.") + fmt.Println("No shipped plugins found.") } if len(userPlugins) > 0 { - fmt.Printf("\nUser-added Plugins (%s):\n", pluginDir) + fmt.Printf("\nUser-installed Plugins (%s):\n", userPluginDir) for _, plugin := range userPlugins { + pluginName := getPluginName(filepath.Join(userPluginDir, plugin)) + fmt.Println(" -", pluginName) + } + } else { + fmt.Println("No user-installed plugins found.") + } + + if len(devPlugins) > 0 { + fmt.Printf("\nDevelopment Plugins (%s):\n", pluginDir) + + for _, plugin := range devPlugins { pluginName := getPluginName(filepath.Join(pluginDir, plugin)) fmt.Println(" -", pluginName) } } else { - fmt.Printf("No user-added plugins found.") + fmt.Println("No development plugins found.") } return nil @@ -212,8 +325,11 @@ func pluginBasePathListForDir(pluginDir string, baseURL string) ([]string, error _, err := os.Stat(pluginPath) if err != nil { - logger.Log(logger.LevelInfo, map[string]string{"pluginPath": pluginPath}, - err, "Not including plugin path, main.js not found") + // Only log if it's not a "does not exist" error (which is expected during deletion) + if !os.IsNotExist(err) { + logger.Log(logger.LevelInfo, map[string]string{"pluginPath": pluginPath}, + err, "Not including plugin path, error checking main.js") + } continue } @@ -222,9 +338,12 @@ func pluginBasePathListForDir(pluginDir string, baseURL string) ([]string, error _, err = os.Stat(packageJSONPath) if err != nil { - logger.Log(logger.LevelInfo, map[string]string{"packageJSONPath": packageJSONPath}, - err, `Not including plugin path, package.json not found. + // Only log if it's not a "does not exist" error (which is expected during deletion) + if !os.IsNotExist(err) { + logger.Log(logger.LevelInfo, map[string]string{"packageJSONPath": packageJSONPath}, + err, `Not including plugin path, package.json not found. Please run 'headlamp-plugin extract' again with headlamp-plugin >= 0.6.0`) + } } pluginFileURL := filepath.Join(baseURL, f.Name()) @@ -255,7 +374,7 @@ func canSendRefresh(c cache.Cache[interface{}]) bool { // HandlePluginEvents handles the plugin events by updating the plugin list // and plugin refresh key in the cache. -func HandlePluginEvents(staticPluginDir, pluginDir string, +func HandlePluginEvents(staticPluginDir, userPluginDir, pluginDir string, notify <-chan string, cache cache.Cache[interface{}], ) { for range notify { @@ -268,7 +387,7 @@ func HandlePluginEvents(staticPluginDir, pluginDir string, } // generate the plugin list - pluginList, err := GeneratePluginPaths(staticPluginDir, pluginDir) + pluginList, err := GeneratePluginPaths(staticPluginDir, userPluginDir, pluginDir) if err != nil && !os.IsNotExist(err) { logger.Log(logger.LevelError, nil, err, "generating plugins path") } @@ -281,7 +400,7 @@ func HandlePluginEvents(staticPluginDir, pluginDir string, } // PopulatePluginsCache populates the plugin list and plugin refresh key in the cache. -func PopulatePluginsCache(staticPluginDir, pluginDir string, cache cache.Cache[interface{}]) { +func PopulatePluginsCache(staticPluginDir, userPluginDir, pluginDir string, cache cache.Cache[interface{}]) { // set the plugin refresh key to false err := cache.Set(context.Background(), PluginRefreshKey, false) if err != nil { @@ -290,10 +409,10 @@ func PopulatePluginsCache(staticPluginDir, pluginDir string, cache cache.Cache[i } // generate the plugin list - pluginList, err := GeneratePluginPaths(staticPluginDir, pluginDir) + pluginList, err := GeneratePluginPaths(staticPluginDir, userPluginDir, pluginDir) if err != nil && !os.IsNotExist(err) { logger.Log(logger.LevelError, - map[string]string{"staticPluginDir": staticPluginDir, "pluginDir": pluginDir}, + map[string]string{"staticPluginDir": staticPluginDir, "userPluginDir": userPluginDir, "pluginDir": pluginDir}, err, "generating plugins path") } @@ -342,20 +461,80 @@ 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) +// tryDeletePlugin attempts to delete the plugin at the given directory and filename. +// It returns true if the plugin was deleted, false if it did not exist. +// It returns an error if there was an issue during deletion. +func tryDeletePlugin(dir string, filename string) (bool, error) { + if dir == "" { + return false, nil + } + + absDir, err := filepath.Abs(dir) if err != nil { - return err + return false, err + } + + absPath := filepath.Join(absDir, filename) + if _, err := os.Stat(absPath); err != nil { + if os.IsNotExist(err) { + return false, nil + } + + return false, err + } + + if !isSubdirectory(absDir, absPath) { + return false, fmt.Errorf("plugin path '%s' is not a subdirectory of '%s'", absPath, absDir) + } + + if err := os.RemoveAll(absPath); err != nil && !os.IsNotExist(err) { + return false, err + } + + return true, nil +} + +// Delete deletes the plugin from the appropriate plugin directory (user or development). +// Shipped plugins cannot be deleted. +// If pluginType is specified ("user" or "development"), only that directory is checked. +// If pluginType is empty, it checks user-plugins first, then development (for backward compatibility). +// Returns an error if the plugin is not found or if it's a shipped plugin. +func Delete(userPluginDir, pluginDir, filename, pluginType string) error { + // Validate plugin type if provided + if pluginType != "" && pluginType != PluginTypeUser && pluginType != PluginTypeDevelopment { + return fmt.Errorf("invalid plugin type '%s': must be 'user' or 'development'", pluginType) } - absPluginPath := path.Join(absPluginDir, filename) + // Attempt deletion according to requested/implicit order + deleted := false - if !isSubdirectory(absPluginDir, absPluginPath) { - return fmt.Errorf("plugin path '%s' is not a subdirectory of '%s'", absPluginPath, absPluginDir) + var err error + + if pluginType != PluginTypeDevelopment { + if deleted, err = tryDeletePlugin(userPluginDir, filename); err != nil { + return err + } + + if pluginType == PluginTypeUser && !deleted { + return fmt.Errorf("plugin '%s' not found in user-plugins directory", filename) + } } - return os.RemoveAll(absPluginPath) + if !deleted && (pluginType == "" || pluginType == PluginTypeDevelopment) { + if deleted, err = tryDeletePlugin(pluginDir, filename); err != nil { + return err + } + + if pluginType == PluginTypeDevelopment && !deleted { + return fmt.Errorf("plugin '%s' not found in development directory", filename) + } + } + + if !deleted { + return fmt.Errorf("plugin '%s' not found or cannot be deleted (shipped plugins cannot be deleted)", filename) + } + + return nil } func isSubdirectory(parentDir, dirPath string) bool { diff --git a/backend/pkg/plugins/plugins_test.go b/backend/pkg/plugins/plugins_test.go index 97d76d26682..40fbcea8558 100644 --- a/backend/pkg/plugins/plugins_test.go +++ b/backend/pkg/plugins/plugins_test.go @@ -128,16 +128,18 @@ func TestGeneratePluginPaths(t *testing.T) { //nolint:funlen _, err = os.Create(packageJSONPath) require.NoError(t, err) - pathList, err := plugins.GeneratePluginPaths("", testDirName) + pathList, err := plugins.GeneratePluginPaths("", "", testDirName) require.NoError(t, err) - require.Contains(t, pathList, "plugins/"+subDirName) + require.Len(t, pathList, 1) + require.Equal(t, "plugins/"+subDirName, pathList[0].Path) + require.Equal(t, "development", pathList[0].Type) // delete the sub directory err = os.RemoveAll(subDir) require.NoError(t, err) // test without any valid plugin - pathList, err = plugins.GeneratePluginPaths("", testDirName) + pathList, err = plugins.GeneratePluginPaths("", "", testDirName) require.NoError(t, err) require.Empty(t, pathList) }) @@ -158,16 +160,18 @@ func TestGeneratePluginPaths(t *testing.T) { //nolint:funlen _, err = os.Create(packageJSONPath) require.NoError(t, err) - pathList, err := plugins.GeneratePluginPaths(testDirName, "") + pathList, err := plugins.GeneratePluginPaths(testDirName, "", "") require.NoError(t, err) - require.Contains(t, pathList, "static-plugins/"+subDirName) + require.Len(t, pathList, 1) + require.Equal(t, "static-plugins/"+subDirName, pathList[0].Path) + require.Equal(t, "shipped", pathList[0].Type) // delete the sub directory err = os.RemoveAll(subDir) require.NoError(t, err) // test without any valid plugin - pathList, err = plugins.GeneratePluginPaths(testDirName, "") + pathList, err = plugins.GeneratePluginPaths(testDirName, "", "") require.NoError(t, err) require.Empty(t, pathList) }) @@ -185,7 +189,7 @@ func TestGeneratePluginPaths(t *testing.T) { //nolint:funlen require.NoError(t, err) // test with file as plugin Dir - pathList, err := plugins.GeneratePluginPaths(fileName, "") + pathList, err := plugins.GeneratePluginPaths(fileName, "", "") assert.Error(t, err) assert.Nil(t, pathList) }) @@ -265,21 +269,21 @@ func TestListPlugins(t *testing.T) { // capture the output of the ListPlugins function output, err := captureOutput(func() { - err := plugins.ListPlugins(staticPluginDir, pluginDir) + err := plugins.ListPlugins(staticPluginDir, "", pluginDir) require.NoError(t, err) }) require.NoError(t, err) - require.Contains(t, output, "Static Plugins") + require.Contains(t, output, "Shipped Plugins") require.Contains(t, output, "static-plugin-1") - require.Contains(t, output, "User-added Plugins") + require.Contains(t, output, "Development Plugins") require.Contains(t, output, "user-plugin-1") // test missing package.json os.Remove(path.Join(plugin1Dir, "package.json")) output, err = captureOutput(func() { - err := plugins.ListPlugins(staticPluginDir, pluginDir) + err := plugins.ListPlugins(staticPluginDir, "", pluginDir) require.NoError(t, err) }) require.NoError(t, err) @@ -289,7 +293,7 @@ func TestListPlugins(t *testing.T) { err = os.WriteFile(path.Join(plugin1Dir, "package.json"), []byte("invalid json"), 0o600) require.NoError(t, err) output, err = captureOutput(func() { - err := plugins.ListPlugins(staticPluginDir, pluginDir) + err := plugins.ListPlugins(staticPluginDir, "", pluginDir) require.NoError(t, err) }) require.NoError(t, err) @@ -331,7 +335,7 @@ func TestHandlePluginEvents(t *testing.T) { //nolint:funlen // create cache ch := cache.New[interface{}]() - go plugins.HandlePluginEvents("", testDirPath, events, ch) + go plugins.HandlePluginEvents("", "", testDirPath, events, ch) // plugin list key should be empty pluginList, err := ch.Get(context.Background(), plugins.PluginListKey) @@ -372,7 +376,7 @@ func TestHandlePluginEvents(t *testing.T) { //nolint:funlen err = ch.Delete(context.Background(), plugins.PluginListKey) require.NoError(t, err) - go plugins.HandlePluginEvents("", testDirPath, events, ch) + go plugins.HandlePluginEvents("", "", testDirPath, events, ch) // send event events <- "test" @@ -398,9 +402,11 @@ func TestHandlePluginEvents(t *testing.T) { //nolint:funlen require.NoError(t, err) require.NotNil(t, pluginList) - pluginListArr, ok := pluginList.([]string) + pluginListArr, ok := pluginList.([]plugins.PluginMetadata) require.True(t, ok) - require.Contains(t, pluginListArr, "plugins/"+pluginDirName) + require.Len(t, pluginListArr, 1) + require.Equal(t, "plugins/"+pluginDirName, pluginListArr[0].Path) + require.Equal(t, "development", pluginListArr[0].Type) // clean up err = os.RemoveAll(testDirPath) @@ -453,7 +459,7 @@ func TestPopulatePluginsCache(t *testing.T) { ch := cache.New[interface{}]() // call PopulatePluginsCache - plugins.PopulatePluginsCache("", "", ch) + plugins.PopulatePluginsCache("", "", "", ch) // check if the plugin refresh key is set to false pluginRefresh, err := ch.Get(context.Background(), plugins.PluginRefreshKey) @@ -467,44 +473,158 @@ func TestPopulatePluginsCache(t *testing.T) { pluginList, err := ch.Get(context.Background(), plugins.PluginListKey) require.NoError(t, err) - pluginListArr, ok := pluginList.([]string) + pluginListArr, ok := pluginList.([]plugins.PluginMetadata) require.True(t, ok) require.Empty(t, pluginListArr) } // TestDelete checks the Delete function. +// +//nolint:funlen 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") + // Create user-plugins directory + userPluginDir := path.Join(tempDir, "user-plugins") + err = os.Mkdir(userPluginDir, 0o755) + require.NoError(t, err) + + // Create development plugins directory + devPluginDir := path.Join(tempDir, "plugins") + err = os.Mkdir(devPluginDir, 0o755) + require.NoError(t, err) + + // Create a user plugin + userPluginPath := path.Join(userPluginDir, "user-plugin-1") + err = os.Mkdir(userPluginPath, 0o755) + require.NoError(t, err) + + // Create a dev plugin + devPluginPath := path.Join(devPluginDir, "dev-plugin-1") + err = os.Mkdir(devPluginPath, 0o755) + require.NoError(t, err) + + // Create a plugin with the same name in both directories for type-specific deletion tests + sharedPluginUser := path.Join(userPluginDir, "shared-plugin") + err = os.Mkdir(sharedPluginUser, 0o755) + require.NoError(t, err) + + sharedPluginDev := path.Join(devPluginDir, "shared-plugin") + err = os.Mkdir(sharedPluginDev, 0o755) require.NoError(t, err) - tempFile.Close() // Close the file // Test cases tests := []struct { - pluginDir string - pluginName string - expectErr bool + name string + userPluginDir string + devPluginDir string + pluginName string + pluginType string + expectErr bool + errContains string }{ - {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 - + { + name: "Delete user plugin (no type specified)", + userPluginDir: userPluginDir, + devPluginDir: devPluginDir, + pluginName: "user-plugin-1", + pluginType: "", + expectErr: false, + }, + { + name: "Delete dev plugin (no type specified)", + userPluginDir: userPluginDir, + devPluginDir: devPluginDir, + pluginName: "dev-plugin-1", + pluginType: "", + expectErr: false, + }, + { + name: "Delete user plugin with type=user", + userPluginDir: userPluginDir, + devPluginDir: devPluginDir, + pluginName: "shared-plugin", + pluginType: "user", + expectErr: false, + }, + { + name: "Delete dev plugin with type=development", + userPluginDir: userPluginDir, + devPluginDir: devPluginDir, + pluginName: "shared-plugin", + pluginType: "development", + expectErr: false, + }, + { + name: "Invalid plugin type", + userPluginDir: userPluginDir, + devPluginDir: devPluginDir, + pluginName: "shared-plugin", + pluginType: "invalid", + expectErr: true, + errContains: "invalid plugin type", + }, + { + name: "Non-existent plugin with type=user", + userPluginDir: userPluginDir, + devPluginDir: devPluginDir, + pluginName: "non-existent", + pluginType: "user", + expectErr: true, + errContains: "not found in user-plugins directory", + }, + { + name: "Non-existent plugin with type=development", + userPluginDir: userPluginDir, + devPluginDir: devPluginDir, + pluginName: "non-existent", + pluginType: "development", + expectErr: true, + errContains: "not found in development directory", + }, + { + name: "Non-existent plugin (no type specified)", + userPluginDir: userPluginDir, + devPluginDir: devPluginDir, + pluginName: "non-existent", + pluginType: "", + expectErr: true, + errContains: "not found or cannot be deleted", + }, + { + name: "Directory traversal attempt", + userPluginDir: userPluginDir, + devPluginDir: devPluginDir, + pluginName: "../", + pluginType: "", + expectErr: true, + }, } for _, tt := range tests { - t.Run(tt.pluginName, func(t *testing.T) { - err := plugins.Delete(tt.pluginDir, tt.pluginName) + t.Run(tt.name, func(t *testing.T) { + err := plugins.Delete(tt.userPluginDir, tt.devPluginDir, tt.pluginName, tt.pluginType) if tt.expectErr { assert.Error(t, err, "Delete should return an error") + + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } } 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") + assert.NoError(t, err, "Delete should not return an error") + // check if the plugin was deleted from the correct directory + if tt.pluginType == "user" || (tt.pluginType == "" && tt.pluginName == "user-plugin-1") { + userPath := path.Join(tt.userPluginDir, tt.pluginName) + _, userErr := os.Stat(userPath) + assert.True(t, os.IsNotExist(userErr), "User plugin should be deleted") + } else if tt.pluginType == "development" || (tt.pluginType == "" && tt.pluginName == "dev-plugin-1") { + devPath := path.Join(tt.devPluginDir, tt.pluginName) + _, devErr := os.Stat(devPath) + assert.True(t, os.IsNotExist(devErr), "Dev plugin should be deleted") + } } }) } diff --git a/e2e-tests/tests/headlamp.spec.ts b/e2e-tests/tests/headlamp.spec.ts index a6e499701c6..0ecae11bec0 100644 --- a/e2e-tests/tests/headlamp.spec.ts +++ b/e2e-tests/tests/headlamp.spec.ts @@ -38,7 +38,7 @@ test('GET /plugins/list returns plugins list', async ({ page }) => { const json = await response.json(); expect(json.length).toBeGreaterThan(0); - expect(json.some(str => str.includes('plugins/'))).toBeTruthy(); + expect(json.some(plugin => plugin.path && plugin.path.includes('plugins/'))).toBeTruthy(); }); // --- Plugin tests end --- // diff --git a/frontend/src/components/App/PluginSettings/PluginSettings.stories.tsx b/frontend/src/components/App/PluginSettings/PluginSettings.stories.tsx index a68389f813c..6df6a51ce96 100644 --- a/frontend/src/components/App/PluginSettings/PluginSettings.stories.tsx +++ b/frontend/src/components/App/PluginSettings/PluginSettings.stories.tsx @@ -15,6 +15,7 @@ */ import { Meta, StoryFn } from '@storybook/react'; +import type { PluginInfo } from '../../../plugin/pluginsSlice'; import { TestContext } from '../../../test'; import { PluginSettingsPure, PluginSettingsPureProps } from './PluginSettings'; @@ -43,6 +44,8 @@ function createDemoData(arrSize: number, useHomepage?: boolean) { description: `This is a plugin for this project PLUGIN A${i}`, isEnabled: i % 2 === 0, isCompatible: i % 2 === 0, + type: 'shipped', + isLoaded: true, }; if (useHomepage) { @@ -56,6 +59,127 @@ function createDemoData(arrSize: number, useHomepage?: boolean) { return pluginArr; } +/** + * createPluginsWithMultipleLocations creates example data showing the same plugin + * installed in different locations (development, user, shipped) with proper priority handling. + */ +function createPluginsWithMultipleLocations(): PluginInfo[] { + return [ + // Plugin installed in all three locations - development version loads + { + name: 'awesome-plugin', + description: 'An awesome plugin installed in development folder', + type: 'development' as const, + isEnabled: true, + isCompatible: true, + isLoaded: true, + homepage: 'https://example.com/awesome-plugin', + }, + { + name: 'awesome-plugin', + description: 'An awesome plugin installed in user folder', + type: 'user' as const, + isEnabled: true, + isCompatible: true, + isLoaded: false, + overriddenBy: 'development' as const, + homepage: 'https://example.com/awesome-plugin', + }, + { + name: 'awesome-plugin', + description: 'An awesome plugin shipped with Headlamp', + type: 'shipped' as const, + isEnabled: true, + isCompatible: true, + isLoaded: false, + overriddenBy: 'development' as const, + homepage: 'https://example.com/awesome-plugin', + }, + // Plugin in user and shipped - user version loads + { + name: 'monitoring-plugin', + description: 'Monitoring plugin installed by user', + type: 'user' as const, + isEnabled: true, + isCompatible: true, + isLoaded: true, + homepage: 'https://example.com/monitoring-plugin', + repository: { url: 'https://github.com/example/monitoring-plugin' }, + }, + { + name: 'monitoring-plugin', + description: 'Monitoring plugin shipped with Headlamp', + type: 'shipped' as const, + isEnabled: true, + isCompatible: true, + isLoaded: false, + overriddenBy: 'user' as const, + homepage: 'https://example.com/monitoring-plugin', + repository: { url: 'https://github.com/example/monitoring-plugin' }, + }, + // Plugin only in development + { + name: 'dev-only-plugin', + description: 'A plugin only in development folder', + type: 'development' as const, + isEnabled: true, + isCompatible: true, + isLoaded: true, + homepage: 'https://example.com/dev-only', + }, + // Plugin only in user folder + { + name: 'custom-plugin', + description: 'A custom plugin installed by user', + type: 'user' as const, + isEnabled: true, + isCompatible: true, + isLoaded: true, + homepage: 'https://example.com/custom-plugin', + repository: { url: 'https://github.com/example/custom-plugin' }, + }, + // Plugin only shipped + { + name: 'default-plugin', + description: 'A default plugin shipped with Headlamp', + type: 'shipped' as const, + isEnabled: true, + isCompatible: true, + isLoaded: true, + homepage: 'https://headlamp.dev/plugins/default', + }, + // Disabled development plugin - user version loads instead + { + name: 'flexible-plugin', + description: 'Flexible plugin in development (disabled)', + type: 'development' as const, + isEnabled: false, + isCompatible: true, + isLoaded: false, + homepage: 'https://example.com/flexible-plugin', + }, + { + name: 'flexible-plugin', + description: 'Flexible plugin installed by user', + type: 'user' as const, + isEnabled: true, + isCompatible: true, + isLoaded: true, + homepage: 'https://example.com/flexible-plugin', + }, + { + name: 'flexible-plugin', + description: 'Flexible plugin shipped with Headlamp', + type: 'shipped' as const, + isEnabled: true, + isCompatible: true, + isLoaded: false, + overriddenBy: 'user' as const, + homepage: 'https://example.com/flexible-plugin', + }, + ]; +} + /** * Creation of data arrays ranging from 0 to 50 to demo state of empty, few, many, and large numbers of data objects. * NOTE: The numbers used are up to the users preference. @@ -114,3 +238,77 @@ EmptyHomepageItems.args = { console.log('Empty Homepage', plugins); }, }; + +/** + * createMigrationScenario creates example data showing the migration behavior + * where catalog-installed plugins in the old plugins directory are treated as "user" plugins. + * + * The backend detects catalog-installed plugins by checking for isManagedByHeadlampPlugin=true + * in the plugin's package.json. These plugins are automatically reclassified from "development" + * to "user" type, ensuring correct priority order (development > user > shipped). + */ +function createMigrationScenario(): PluginInfo[] { + return [ + // Catalog-installed plugin in old location (plugins dir) - treated as "user" type + // This simulates a plugin that was installed via the catalog before the user-plugins directory existed. + // Backend checks package.json for isManagedByHeadlampPlugin=true and reclassifies it as "user" type. + { + name: 'prometheus', + description: + 'Prometheus monitoring plugin (catalog-installed in old location, has isManagedByHeadlampPlugin=true)', + type: 'user' as const, // Backend detects isManagedByHeadlampPlugin=true and treats as user + isEnabled: true, + isCompatible: true, + isLoaded: true, + homepage: 'https://artifacthub.io/packages/headlamp/headlamp/prometheus', + }, + // New catalog-installed plugin in correct location (user-plugins) + { + name: 'flux', + description: 'Flux GitOps plugin (catalog-installed in new location)', + type: 'user' as const, + isEnabled: true, + isCompatible: true, + isLoaded: true, + homepage: 'https://artifacthub.io/packages/headlamp/headlamp/flux', + }, + // True development plugin - gets higher priority + { + name: 'my-dev-plugin', + description: 'A plugin being actively developed', + type: 'development' as const, + isEnabled: true, + isCompatible: true, + isLoaded: true, + homepage: 'https://example.com/my-dev-plugin', + }, + // Shipped plugin + { + name: 'default-dashboard', + description: 'Default dashboard plugin', + type: 'shipped' as const, + isEnabled: true, + isCompatible: true, + isLoaded: true, + homepage: 'https://headlamp.dev/plugins/dashboard', + }, + ]; +} + +/** Story showing plugins installed in multiple locations with priority handling */ +export const MultipleLocations = Template.bind({}); +MultipleLocations.args = { + plugins: createPluginsWithMultipleLocations(), + onSave: (plugins: any) => { + console.log('Multiple Locations', plugins); + }, +}; + +/** Story demonstrating migration of catalog-installed plugins from old location */ +export const MigrationScenario = Template.bind({}); +MigrationScenario.args = { + plugins: createMigrationScenario(), + onSave: (plugins: any) => { + console.log('Migration Scenario', plugins); + }, +}; diff --git a/frontend/src/components/App/PluginSettings/PluginSettings.tsx b/frontend/src/components/App/PluginSettings/PluginSettings.tsx index 0bab02bbc15..0759a60e89d 100644 --- a/frontend/src/components/App/PluginSettings/PluginSettings.tsx +++ b/frontend/src/components/App/PluginSettings/PluginSettings.tsx @@ -16,10 +16,12 @@ import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; import Link from '@mui/material/Link'; import { useTheme } from '@mui/material/styles'; import { SwitchProps } from '@mui/material/Switch'; import Switch from '@mui/material/Switch'; +import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import { MRT_Row } from 'material-react-table'; import { useEffect, useState } from 'react'; @@ -124,6 +126,7 @@ export function PluginSettingsPure(props: PluginSettingsPureProps) { /** * pluginChanges state is the array of plugin data and any current changes made by the user to a plugin's "Enable" field via toggler. * The name and origin fields are split for consistency. + * Plugins that are not loaded (isLoaded === false) are initialized with isEnabled = false. */ const [pluginChanges, setPluginChanges] = useState(() => pluginArr.map((plugin: PluginInfo) => { @@ -135,6 +138,8 @@ export function PluginSettingsPure(props: PluginSettingsPureProps) { ...plugin, displayName: name ?? plugin.name, origin: plugin.origin ?? author?.substring(1) ?? t('translation|Unknown'), + // If the plugin is not loaded, ensure it's disabled + isEnabled: plugin.isLoaded === false ? false : plugin.isEnabled, }; }) ); @@ -145,13 +150,15 @@ export function PluginSettingsPure(props: PluginSettingsPureProps) { * If props.plugins matches pluginChanges enableSave is set to false, disabling the save button. */ useEffect(() => { - /** This matcher function compares the fields of name and isEnabled of each object in props.plugins to each object in pluginChanges */ + /** This matcher function compares the fields of name, type and isEnabled of each object in props.plugins to each object in pluginChanges */ function matcher(objA: PluginInfo, objB: PluginInfo) { - return objA.name === objB.name && objA.isEnabled === objB.isEnabled; + return ( + objA.name === objB.name && objA.type === objB.type && objA.isEnabled === objB.isEnabled + ); } /** - * arrayComp returns true if each object in both arrays are identical by name and isEnabled. + * arrayComp returns true if each object in both arrays are identical by name, type and isEnabled. * If both arrays are identical in this scope, then no changes need to be saved. * If they do not match, there are changes in the pluginChanges array that can be saved and thus enableSave should be enabled. */ @@ -182,14 +189,23 @@ export function PluginSettingsPure(props: PluginSettingsPureProps) { * On change function handler to control the enableSave state and update the pluginChanges state. * This function is called on every plugin toggle action and recreates the state for pluginChanges. * Once the user clicks a toggle, the Save button is also rendered via setEnableSave. + * Now handles plugins by both name and type to support multiple versions of the same plugin. + * When enabling a plugin, it automatically disables other versions of the same plugin. */ - function switchChangeHanlder(plug: { name: any }) { + function switchChangeHanlder(plug: { name: any; type?: string; isEnabled?: boolean }) { const plugName = plug.name; + const plugType = plug.type; + const newEnabledState = !plug.isEnabled; setPluginChanges((currentInfo: any[]) => - currentInfo.map((p: { name: any; isEnabled: any }) => { - if (p.name === plugName) { - return { ...p, isEnabled: !p.isEnabled }; + currentInfo.map((p: { name: any; type?: string; isEnabled: any }) => { + // Match by both name and type to handle multiple versions + if (p.name === plugName && p.type === plugType) { + return { ...p, isEnabled: newEnabledState }; + } + // If we're enabling this plugin, disable other versions with the same name + if (newEnabledState && p.name === plugName && p.type !== plugType) { + return { ...p, isEnabled: false }; } return p; }) @@ -214,16 +230,30 @@ export function PluginSettingsPure(props: PluginSettingsPureProps) { }, }, Cell: ({ row: { original: plugin } }: { row: MRT_Row }) => { + // Check if there are clashing plugins (same name, different type) + const hasClashingPlugins = pluginChanges.some( + (p: PluginInfo) => p.name === plugin.name && p.type !== plugin.type + ); + return ( <> {plugin.displayName} + {hasClashingPlugins && ( + + )} {plugin.version} @@ -234,6 +264,28 @@ export function PluginSettingsPure(props: PluginSettingsPureProps) { header: t('translation|Description'), accessorKey: 'description', }, + { + header: t('translation|Type'), + accessorFn: (plugin: PluginInfo) => plugin.type || 'unknown', + Cell: ({ row: { original: plugin } }: { row: MRT_Row }) => { + const typeLabels: Record = { + development: { + label: t('translation|Development'), + color: 'primary', + }, + user: { + label: t('translation|User-installed'), + color: 'info', + }, + shipped: { + label: t('translation|Shipped'), + color: 'default', + }, + }; + const typeInfo = typeLabels[plugin.type || 'shipped']; + return ; + }, + }, { header: t('translation|Origin'), Cell: ({ row: { original: plugin } }: { row: MRT_Row }) => { @@ -249,14 +301,58 @@ export function PluginSettingsPure(props: PluginSettingsPureProps) { ); }, }, - // TODO: Fetch the plugin status from the plugin settings store { header: t('translation|Status'), - accessorFn: (plugin: PluginInfo) => { + Cell: ({ row: { original: plugin } }: { row: MRT_Row }) => { if (plugin.isCompatible === false) { - return t('translation|Incompatible'); + return ( + + + + ); } - return plugin.isEnabled ? t('translation|Enabled') : t('translation|Disabled'); + + // Show if this plugin is overridden by a higher priority version + if (plugin.isLoaded === false && plugin.overriddenBy) { + const overrideLabels: Record = { + development: t('translation|Development'), + user: t('translation|User-installed'), + shipped: t('translation|Shipped'), + }; + return ( + + + + ); + } + + // Show if disabled + if (plugin.isEnabled === false) { + return ( + + ); + } + + // Show if loaded and enabled + return ; }, }, { @@ -266,10 +362,19 @@ export function PluginSettingsPure(props: PluginSettingsPureProps) { if (!plugin.isCompatible || !isElectron()) { return null; } + + // Find the current state of this plugin in pluginChanges + const currentPlugin = pluginChanges.find( + (p: PluginInfo) => p.name === plugin.name && p.type === plugin.type + ); + + // Plugin should be checked if it's enabled in the current state + const isChecked = currentPlugin?.isEnabled !== false; + return ( switchChangeHanlder(plugin)} color="primary" name={plugin.name} @@ -282,16 +387,54 @@ export function PluginSettingsPure(props: PluginSettingsPureProps) { .filter(el => !(el.header === t('translation|Enable') && !isElectron()))} data={pluginChanges} filterFunction={useFilterFunc(['.name'])} + muiTableBodyRowProps={({ row }) => { + const plugin = row.original as PluginInfo; + // Check if there are clashing plugins (same name, different type) + const hasClashingPlugins = pluginChanges.some( + (p: PluginInfo) => p.name === plugin.name && p.type !== plugin.type + ); + + // Generate a consistent color based on plugin name + if (hasClashingPlugins) { + const hash = plugin.name.split('').reduce((acc, char) => { + return char.charCodeAt(0) + ((acc << 5) - acc); + }, 0); + const hue = Math.abs(hash) % 360; + + return { + sx: { + backgroundColor: theme => + theme.palette.mode === 'dark' + ? `hsla(${hue}, 30%, 20%, 0.3)` + : `hsla(${hue}, 50%, 85%, 0.4)`, + '&:hover': { + backgroundColor: theme => + theme.palette.mode === 'dark' + ? `hsla(${hue}, 30%, 25%, 0.4) !important` + : `hsla(${hue}, 50%, 80%, 0.5) !important`, + }, + }, + }; + } + return {}; + }} /> - {enableSave && ( - - diff --git a/frontend/src/components/App/PluginSettings/PluginSettingsDetails.tsx b/frontend/src/components/App/PluginSettings/PluginSettingsDetails.tsx index 1b9142ecd15..1c04e26fe47 100644 --- a/frontend/src/components/App/PluginSettings/PluginSettingsDetails.tsx +++ b/frontend/src/components/App/PluginSettings/PluginSettingsDetails.tsx @@ -16,26 +16,57 @@ import Box, { BoxProps } from '@mui/material/Box'; import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; 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 { useHistory } from 'react-router-dom'; +import { useHistory, useParams } from 'react-router-dom'; import { isElectron } from '../../../helpers/isElectron'; -import { getCluster } from '../../../lib/cluster'; import { deletePlugin } from '../../../lib/k8s/api/v1/pluginsApi'; import { ConfigStore } from '../../../plugin/configStore'; -import { PluginInfo } from '../../../plugin/pluginsSlice'; +import { PluginInfo, reloadPage } from '../../../plugin/pluginsSlice'; import { clusterAction } from '../../../redux/clusterActionSlice'; import { useTypedSelector } from '../../../redux/hooks'; import type { AppDispatch } from '../../../redux/stores/store'; import NotFoundComponent from '../../404'; +import { SectionHeader } from '../../common'; +import ActionButton from '../../common/ActionButton'; import { ConfirmDialog } from '../../common/Dialog'; import ErrorBoundary from '../../common/ErrorBoundary'; import { SectionBox } from '../../common/SectionBox'; -import { setNotifications } from '../Notifications/notificationsSlice'; + +// Helper function to open plugin folder in file explorer (Electron only) +function openPluginFolder(plugin: PluginInfo) { + if (!isElectron()) { + return; + } + + const folderName = plugin.folderName || plugin.name.split('/').pop(); + if (!folderName || !plugin.type) { + return; + } + + const { desktopApi } = window as any; + if (desktopApi?.send) { + desktopApi.send('open-plugin-folder', { + folderName, + type: plugin.type, + }); + } +} + +// Helper to check if we can open the plugin folder +function canOpenPluginFolder(plugin: PluginInfo): boolean { + if (!isElectron()) { + return false; + } + + const folderName = plugin.folderName || plugin.name.split('/').pop(); + return !!(folderName && plugin.type); +} const PluginSettingsDetailsInitializer = (props: { plugin: PluginInfo }) => { const { plugin } = props; @@ -44,40 +75,34 @@ const PluginSettingsDetailsInitializer = (props: { plugin: PluginInfo }) => { const pluginConf = store.useConfig(); const config = pluginConf() as { [key: string]: any }; const dispatch: AppDispatch = useDispatch(); + const history = useHistory(); function handleSave(data: { [key: string]: any }) { store.set(data); } function handleDeleteConfirm() { - const name = plugin.name.split('/').splice(-1)[0]; + // Use folderName if available (the actual folder name on disk), + // otherwise fall back to extracting from the name + const pluginFolderName = plugin.folderName || plugin.name.split('/').splice(-1)[0]; + + // Determine plugin type for deletion - only allow deletion of user and development plugins + const pluginType = + plugin.type === 'development' || plugin.type === 'user' ? plugin.type : undefined; dispatch( - clusterAction( - () => - deletePlugin(name).catch(err => { - const msg = err instanceof Error ? err.message : t('Unknown error'); - dispatch( - setNotifications({ - cluster: getCluster(), - date: new Date().toISOString(), - deleted: false, - id: Math.random().toString(36).substring(2), - message: t('Failed to delete plugin: {{ msg }}', { msg: msg }), - seen: false, - }) - ); - throw err; - }), - { - startMessage: t('Deleting plugin {{ itemName }}...', { itemName: name }), - cancelledMessage: t('Cancelled deletion of {{ itemName }}.', { itemName: name }), - successMessage: t('Deleted plugin {{ itemName }}.', { itemName: name }), - errorMessage: t('Error deleting plugin {{ itemName }}.', { itemName: name }), - } - ) - ).finally(() => { - history.back(); + clusterAction(() => deletePlugin(pluginFolderName, pluginType), { + startMessage: t('Deleting plugin {{ itemName }}...', { itemName: pluginFolderName }), + cancelledMessage: t('Cancelled deletion of {{ itemName }}.', { + itemName: pluginFolderName, + }), + successMessage: t('Deleted plugin {{ itemName }}.', { itemName: pluginFolderName }), + errorMessage: t('Error deleting plugin {{ itemName }}.', { itemName: pluginFolderName }), + }) + ).then(() => { + // Navigate to plugins list page, then reload to refresh plugin list from backend + history.push('/settings/plugins'); + dispatch(reloadPage()); }); } @@ -93,12 +118,22 @@ const PluginSettingsDetailsInitializer = (props: { plugin: PluginInfo }) => { export default function PluginSettingsDetails() { const pluginSettings = useTypedSelector(state => state.plugins.pluginSettings); - const { name } = useParams<{ name: string }>(); + const { name, type } = useParams<{ name: string; type?: string }>(); const plugin = useMemo(() => { const decodedName = decodeURIComponent(name); + const decodedType = type ? decodeURIComponent(type) : undefined; + + // If type is specified, find exact match by name and type + if (decodedType) { + return pluginSettings.find( + plugin => plugin.name === decodedName && (plugin.type || 'shipped') === decodedType + ); + } + + // Otherwise, find by name only (backwards compatibility) return pluginSettings.find(plugin => plugin.name === decodedName); - }, [pluginSettings, name]); + }, [pluginSettings, name, type]); if (!plugin) { return ; @@ -182,14 +217,19 @@ export function PluginSettingsDetailsPure(props: PluginSettingsDetailsPureProps) } let component; - if (isValidElement(plugin.settingsComponent)) { - component = plugin.settingsComponent; - } else if (typeof plugin.settingsComponent === 'function') { - const Comp = plugin.settingsComponent; - if (plugin.displaySettingsComponentWithSaveButton) { - component = ; + // Only show settings component if this plugin is actually loaded + if (plugin.isLoaded !== false) { + 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 = ; + component = null; } } else { component = null; @@ -199,11 +239,88 @@ export function PluginSettingsDetailsPure(props: PluginSettingsDetailsPureProps) <> + ), + plugin.isLoaded === false && plugin.overriddenBy && ( + + ), + ]} + actions={ + isElectron() + ? [ + ...(canOpenPluginFolder(plugin) + ? [ + openPluginFolder(plugin)} + />, + ] + : []), + ...(plugin.type !== 'shipped' + ? [ + , + ] + : []), + ] + : [] + } + subtitle={author ? `${t('translation|By')}: ${author}` : undefined} + noPadding={false} + headerStyle="subsection" + /> + } backLink={'/settings/plugins'} > {plugin.description} + {plugin.isLoaded === false && plugin.overriddenBy && ( + + + {t( + 'translation|This plugin is not currently loaded because a "{{type}}" version is being used instead.', + { + type: + plugin.overriddenBy === 'development' + ? t('translation|development') + : plugin.overriddenBy === 'user' + ? t('translation|user-installed') + : t('translation|shipped'), + } + )} + + + )} {component} - - - - {plugin.displaySettingsComponentWithSaveButton && ( - <> - - - - )} - - {isElectron() ? ( - + - ) : null} - - + + + )} ); } diff --git a/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.DefaultSaveEnable.stories.storyshot b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.DefaultSaveEnable.stories.storyshot index f14f868f9ca..9e768103042 100644 --- a/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.DefaultSaveEnable.stories.storyshot +++ b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.DefaultSaveEnable.stories.storyshot @@ -151,7 +151,7 @@ - + + plugin a 0 @@ -381,6 +405,19 @@ + plugin a 1 @@ -423,6 +468,19 @@ + plugin a 2 @@ -465,6 +533,19 @@ + plugin a 3 @@ -507,6 +596,19 @@ + plugin a 4 @@ -549,6 +661,19 @@ + @@ -579,19 +712,5 @@ -
- -
\ No newline at end of file diff --git a/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.EmptyHomepageItems.stories.storyshot b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.EmptyHomepageItems.stories.storyshot index aee92a7a3be..9e768103042 100644 --- a/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.EmptyHomepageItems.stories.storyshot +++ b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.EmptyHomepageItems.stories.storyshot @@ -151,7 +151,7 @@
-
- -
-
-
- Status + Type +
+ +
+
+
+
+ +
+
+
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+
+ + Shipped + +
+
- Incompatible +
+ + Incompatible + +
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+
+ + Shipped + +
+
- Incompatible +
+ + Incompatible + +
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
- + + plugin a 0 @@ -381,6 +405,19 @@ + plugin a 1 @@ -423,6 +468,19 @@ + plugin a 2 @@ -465,6 +533,19 @@ + plugin a 3 @@ -507,6 +596,19 @@ + plugin a 4 @@ -549,6 +661,19 @@ + diff --git a/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.FewItems.stories.storyshot b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.FewItems.stories.storyshot index aee92a7a3be..9e768103042 100644 --- a/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.FewItems.stories.storyshot +++ b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.FewItems.stories.storyshot @@ -151,7 +151,7 @@
-
- -
-
-
- Status + Type +
+ +
+
+
+
+ +
+
+
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+
+ + Shipped + +
+
- Incompatible +
+ + Incompatible + +
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+
+ + Shipped + +
+
- Incompatible +
+ + Incompatible + +
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
- + + plugin a 0 @@ -381,6 +405,19 @@ + plugin a 1 @@ -423,6 +468,19 @@ + plugin a 2 @@ -465,6 +533,19 @@ + plugin a 3 @@ -507,6 +596,19 @@ + plugin a 4 @@ -549,6 +661,19 @@ + diff --git a/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.ManyItems.stories.storyshot b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.ManyItems.stories.storyshot index e0ed3e8c893..ac3e0dca691 100644 --- a/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.ManyItems.stories.storyshot +++ b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.ManyItems.stories.storyshot @@ -151,7 +151,7 @@
-
- -
-
-
- Status + Type +
+ +
+
+
+
+ +
+
+
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+
+ + Shipped + +
+
- Incompatible +
+ + Incompatible + +
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+
+ + Shipped + +
+
- Incompatible +
+ + Incompatible + +
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
- + + plugin a 0 @@ -381,6 +405,19 @@ + plugin a 1 @@ -423,6 +468,19 @@ + plugin a 2 @@ -465,6 +533,19 @@ + plugin a 3 @@ -507,6 +596,19 @@ + plugin a 4 @@ -549,6 +661,19 @@ + plugin a 5 @@ -591,6 +724,19 @@ + plugin a 6 @@ -633,6 +789,19 @@ + plugin a 7 @@ -675,6 +852,19 @@ + plugin a 8 @@ -717,6 +917,19 @@ + plugin a 9 @@ -759,6 +980,19 @@ + plugin a 10 @@ -801,6 +1045,19 @@ + plugin a 11 @@ -843,6 +1108,19 @@ + plugin a 12 @@ -885,6 +1173,19 @@ + plugin a 13 @@ -927,6 +1236,19 @@ + plugin a 14 @@ -969,6 +1301,19 @@ + diff --git a/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.MigrationScenario.stories.storyshot b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.MigrationScenario.stories.storyshot new file mode 100644 index 00000000000..8a252b970ad --- /dev/null +++ b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.MigrationScenario.stories.storyshot @@ -0,0 +1,649 @@ + +
+
+
+
+
+

+ Plugins +

+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+ +
+
+ + + +
+
+
+
+
-
- -
-
-
- Status + Type +
+ +
+
+
+
+ +
+
+
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+
+ + Shipped + +
+
- Incompatible +
+ + Incompatible + +
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+
+ + Shipped + +
+
- Incompatible +
+ + Incompatible + +
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+
+ + Shipped + +
+
- Incompatible +
+ + Incompatible + +
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+
+ + Shipped + +
+
- Incompatible +
+ + Incompatible + +
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+
+ + Shipped + +
+
- Incompatible +
+ + Incompatible + +
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+
+ + Shipped + +
+
- Incompatible +
+ + Incompatible + +
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+
+ + Shipped + +
+
- Incompatible +
+ + Incompatible + +
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ + prometheus + +
+ +
+ Prometheus monitoring plugin (catalog-installed in old location, has isManagedByHeadlampPlugin=true) + +
+ + User-installed + +
+
+ + Unknown + + +
+ + Loaded + +
+
+
+ + flux + +
+ +
+ Flux GitOps plugin (catalog-installed in new location) + +
+ + User-installed + +
+
+ + Unknown + + +
+ + Loaded + +
+
+
+ + my-dev-plugin + +
+ +
+ A plugin being actively developed + +
+ + Development + +
+
+ + Unknown + + +
+ + Loaded + +
+
+
+ + default-dashboard + +
+ +
+ Default dashboard plugin + +
+ + Shipped + +
+
+ + Unknown + + +
+ + Loaded + +
+
+
+
+ +
+
+
+
+ + + \ No newline at end of file diff --git a/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.MoreItems.stories.storyshot b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.MoreItems.stories.storyshot index 48d18bde109..fc23ba9e882 100644 --- a/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.MoreItems.stories.storyshot +++ b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.MoreItems.stories.storyshot @@ -151,7 +151,7 @@ - + + plugin a 0 @@ -381,6 +405,19 @@ + plugin a 1 @@ -423,6 +468,19 @@ + plugin a 2 @@ -465,6 +533,19 @@ + plugin a 3 @@ -507,6 +596,19 @@ + plugin a 4 @@ -549,6 +661,19 @@ + plugin a 5 @@ -591,6 +724,19 @@ + plugin a 6 @@ -633,6 +789,19 @@ + plugin a 7 @@ -675,6 +852,19 @@ + plugin a 8 @@ -717,6 +917,19 @@ + plugin a 9 @@ -759,6 +980,19 @@ + plugin a 10 @@ -801,6 +1045,19 @@ + plugin a 11 @@ -843,6 +1108,19 @@ + plugin a 12 @@ -885,6 +1173,19 @@ + plugin a 13 @@ -927,6 +1236,19 @@ + plugin a 14 @@ -969,6 +1301,19 @@ + diff --git a/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.MultipleLocations.stories.storyshot b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.MultipleLocations.stories.storyshot new file mode 100644 index 00000000000..59e5250b4f9 --- /dev/null +++ b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettings.MultipleLocations.stories.storyshot @@ -0,0 +1,1170 @@ + +
+
+
+
+
+

+ Plugins +

+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+ +
+
+ + + +
+
+
+
+
-
- -
-
-
- Status + Type +
+ +
+
+
+
+ +
+
+
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+
+ + Shipped + +
+
- Incompatible +
+ + Incompatible + +
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+
+ + Shipped + +
+
- Incompatible +
+ + Incompatible + +
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+
+ + Shipped + +
+
- Incompatible +
+ + Incompatible + +
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+
+ + Shipped + +
+
- Incompatible +
+ + Incompatible + +
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+
+ + Shipped + +
+
- Incompatible +
+ + Incompatible + +
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+
+ + Shipped + +
+
- Incompatible +
+ + Incompatible + +
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+
+ + Shipped + +
+
- Incompatible +
+ + Incompatible + +
+
+ + Shipped + +
+
- Enabled +
+ + Loaded + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ + awesome-plugin + +
+ + Multiple versions + +
+
+ +
+ An awesome plugin installed in development folder + +
+ + Development + +
+
+ + Unknown + + +
+ + Loaded + +
+
+
+ + awesome-plugin + +
+ + Multiple versions + +
+
+ +
+ An awesome plugin installed in user folder + +
+ + User-installed + +
+
+ + Unknown + + +
+ + Not Loaded + +
+
+
+ + awesome-plugin + +
+ + Multiple versions + +
+
+ +
+ An awesome plugin shipped with Headlamp + +
+ + Shipped + +
+
+ + Unknown + + +
+ + Not Loaded + +
+
+
+ + monitoring-plugin + +
+ + Multiple versions + +
+
+ +
+ Monitoring plugin installed by user + +
+ + User-installed + +
+
+ + Unknown + + +
+ + Loaded + +
+
+
+ + monitoring-plugin + +
+ + Multiple versions + +
+
+ +
+ Monitoring plugin shipped with Headlamp + +
+ + Shipped + +
+
+ + Unknown + + +
+ + Not Loaded + +
+
+
+ + dev-only-plugin + +
+ +
+ A plugin only in development folder + +
+ + Development + +
+
+ + Unknown + + +
+ + Loaded + +
+
+
+ + custom-plugin + +
+ +
+ A custom plugin installed by user + +
+ + User-installed + +
+
+ + Unknown + + +
+ + Loaded + +
+
+
+ + default-plugin + +
+ +
+ A default plugin shipped with Headlamp + +
+ + Shipped + +
+
+ + Unknown + + +
+ + Loaded + +
+
+
+ + flexible-plugin + +
+ + Multiple versions + +
+
+ +
+ Flexible plugin in development (disabled) + +
+ + Development + +
+
+ + Unknown + + +
+ + Disabled + +
+
+
+ + flexible-plugin + +
+ + Multiple versions + +
+
+ +
+ Flexible plugin installed by user + +
+ + User-installed + +
+
+ + Unknown + + +
+ + Loaded + +
+
+
+ + flexible-plugin + +
+ + Multiple versions + +
+
+ +
+ Flexible plugin shipped with Headlamp + +
+ + Shipped + +
+
+ + Unknown + + +
+ + Not Loaded + +
+
+
+
+ +
+
+
+
+ + + \ No newline at end of file diff --git a/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettingsDetails.WithAutoSave.stories.storyshot b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettingsDetails.WithAutoSave.stories.storyshot index f97feba917e..ed893d219f6 100644 --- a/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettingsDetails.WithAutoSave.stories.storyshot +++ b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettingsDetails.WithAutoSave.stories.storyshot @@ -89,16 +89,5 @@ -
-
-
-
-
\ No newline at end of file diff --git a/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettingsDetails.WithoutAutoSave.stories.storyshot b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettingsDetails.WithoutAutoSave.stories.storyshot index e181660b30b..e31e6ef52c2 100644 --- a/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettingsDetails.WithoutAutoSave.stories.storyshot +++ b/frontend/src/components/App/PluginSettings/__snapshots__/PluginSettingsDetails.WithoutAutoSave.stories.storyshot @@ -93,32 +93,28 @@ class="MuiBox-root css-j1fy4m" >
-
- - -
+ Save + +
diff --git a/frontend/src/components/App/icons.ts b/frontend/src/components/App/icons.ts index e288a3db711..cdfeb4aa5fe 100644 --- a/frontend/src/components/App/icons.ts +++ b/frontend/src/components/App/icons.ts @@ -438,6 +438,9 @@ const mdiIcons = { 'select-group': { body: '\u003Cpath fill="currentColor" d="M5 3a2 2 0 0 0-2 2h2m2-2v2h2V3m2 0v2h2V3m2 0v2h2V3m2 0v2h2a2 2 0 0 0-2-2M3 7v2h2V7m2 0v4h4V7m2 0v4h4V7m2 0v2h2V7M3 11v2h2v-2m14 0v2h2v-2M7 13v4h4v-4m2 0v4h4v-4M3 15v2h2v-2m14 0v2h2v-2M3 19a2 2 0 0 0 2 2v-2m2 0v2h2v-2m2 0v2h2v-2m2 0v2h2v-2m2 0v2a2 2 0 0 0 2-2Z"/\u003E', }, + 'folder-open': { + body: '\u003Cpath fill="currentColor" d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7a2 2 0 0 1 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5"/\u003E', + }, }, aliases: { 'more-vert': { diff --git a/frontend/src/i18n/locales/de/translation.json b/frontend/src/i18n/locales/de/translation.json index 93b350d8ea3..a0012440e5a 100644 --- a/frontend/src/i18n/locales/de/translation.json +++ b/frontend/src/i18n/locales/de/translation.json @@ -90,20 +90,27 @@ "View all notifications": "Alle Benachrichtigungen anzeigen", "Unknown": "", "Plugins": "Plugins", + "Multiple versions": "", "Description": "Beschreibung", + "Type": "Typ", + "Development": "", + "User-installed": "", + "Shipped": "", + "This plugin is not compatible with this version of Headlamp": "", "Incompatible": "Inkompatibel", - "Enabled": "", + "Overridden by {{type}} version": "", + "Not Loaded": "", "Disabled": "", + "Loaded": "", "Enable": "", "Save & Apply": "Speichern & Anwenden", - "Unknown error": "", - "Failed to delete plugin: {{ msg }}": "", "Deleting plugin {{ itemName }}...": "", "Cancelled deletion of {{ itemName }}.": "Löschen von {{ itemName }} abgebrochen.", "Deleted plugin {{ itemName }}.": "", "Error deleting plugin {{ itemName }}.": "", - "By": "", "Delete Plugin": "", + "By": "", + "This plugin is not currently loaded because a \"{{type}}\" version is being used instead.": "", "Are you sure you want to delete this plugin?": "", "Uh-oh! Something went wrong.": "Oh-oh! Etwas ist schief gelaufen.", "Error loading {{ routeName }}": "", @@ -197,7 +204,6 @@ "Clusters successfully set up!": "Cluster erfolgreich eingerichtet!", "Finish": "Beenden", "Only warnings ({{ numWarnings }})": "Nur Warnungen ({{ numWarnings }})", - "Type": "Typ", "Reason": "Ereignis", "Last Seen": "Zuletzt gesehen", "Offline": "Offline", diff --git a/frontend/src/i18n/locales/en/translation.json b/frontend/src/i18n/locales/en/translation.json index 54eef6df29c..fea105e8541 100644 --- a/frontend/src/i18n/locales/en/translation.json +++ b/frontend/src/i18n/locales/en/translation.json @@ -90,20 +90,27 @@ "View all notifications": "View all notifications", "Unknown": "Unknown", "Plugins": "Plugins", + "Multiple versions": "Multiple versions", "Description": "Description", + "Type": "Type", + "Development": "Development", + "User-installed": "User-installed", + "Shipped": "Shipped", + "This plugin is not compatible with this version of Headlamp": "This plugin is not compatible with this version of Headlamp", "Incompatible": "Incompatible", - "Enabled": "Enabled", + "Overridden by {{type}} version": "Overridden by {{type}} version", + "Not Loaded": "Not Loaded", "Disabled": "Disabled", + "Loaded": "Loaded", "Enable": "Enable", "Save & Apply": "Save & Apply", - "Unknown error": "Unknown error", - "Failed to delete plugin: {{ msg }}": "Failed to delete plugin: {{ msg }}", "Deleting plugin {{ itemName }}...": "Deleting plugin {{ itemName }}...", "Cancelled deletion of {{ itemName }}.": "Cancelled deletion of {{ itemName }}.", "Deleted plugin {{ itemName }}.": "Deleted plugin {{ itemName }}.", "Error deleting plugin {{ itemName }}.": "Error deleting plugin {{ itemName }}.", - "By": "By", "Delete Plugin": "Delete Plugin", + "By": "By", + "This plugin is not currently loaded because a \"{{type}}\" version is being used instead.": "This plugin is not currently loaded because a \"{{type}}\" version is being used instead.", "Are you sure you want to delete this plugin?": "Are you sure you want to delete this plugin?", "Uh-oh! Something went wrong.": "Uh-oh! Something went wrong.", "Error loading {{ routeName }}": "Error loading {{ routeName }}", @@ -197,7 +204,6 @@ "Clusters successfully set up!": "Clusters successfully set up!", "Finish": "Finish", "Only warnings ({{ numWarnings }})": "Only warnings ({{ numWarnings }})", - "Type": "Type", "Reason": "Reason", "Last Seen": "Last Seen", "Offline": "Offline", diff --git a/frontend/src/i18n/locales/es/translation.json b/frontend/src/i18n/locales/es/translation.json index 806fd9f7eff..066e8f2ac97 100644 --- a/frontend/src/i18n/locales/es/translation.json +++ b/frontend/src/i18n/locales/es/translation.json @@ -90,20 +90,27 @@ "View all notifications": "Ver todas las notificaciones", "Unknown": "Desconocido", "Plugins": "Plugins", + "Multiple versions": "", "Description": "Descripción", + "Type": "Tipo", + "Development": "", + "User-installed": "", + "Shipped": "", + "This plugin is not compatible with this version of Headlamp": "", "Incompatible": "Incompatible", - "Enabled": "Activado", + "Overridden by {{type}} version": "", + "Not Loaded": "", "Disabled": "Desactivado", + "Loaded": "", "Enable": "Activar", "Save & Apply": "Guardar & Aplicar", - "Unknown error": "", - "Failed to delete plugin: {{ msg }}": "", "Deleting plugin {{ itemName }}...": "", "Cancelled deletion of {{ itemName }}.": "Se ha cancelado la eliminación de {{ itemName }}.", "Deleted plugin {{ itemName }}.": "", "Error deleting plugin {{ itemName }}.": "", - "By": "Por", "Delete Plugin": "Borrar plugin", + "By": "Por", + "This plugin is not currently loaded because a \"{{type}}\" version is being used instead.": "", "Are you sure you want to delete this plugin?": "¿Está seguro de que desea borrar este plugin?", "Uh-oh! Something went wrong.": "¡Ups! Algo ha fallado.", "Error loading {{ routeName }}": "Error al cargar {{ routeName }}", @@ -197,7 +204,6 @@ "Clusters successfully set up!": "¡Clusters configurados con éxito!", "Finish": "Finalizar", "Only warnings ({{ numWarnings }})": "Solo advertencias ({{ numWarnings }})", - "Type": "Tipo", "Reason": "Razón", "Last Seen": "Últi. ocurrencia", "Offline": "Desconectado", diff --git a/frontend/src/i18n/locales/fr/translation.json b/frontend/src/i18n/locales/fr/translation.json index 0aa679ef591..7baff59cc64 100644 --- a/frontend/src/i18n/locales/fr/translation.json +++ b/frontend/src/i18n/locales/fr/translation.json @@ -90,20 +90,27 @@ "View all notifications": "Voir toutes les notifications", "Unknown": "", "Plugins": "Plugins", + "Multiple versions": "", "Description": "Description", + "Type": "Type", + "Development": "", + "User-installed": "", + "Shipped": "", + "This plugin is not compatible with this version of Headlamp": "", "Incompatible": "Incompatible", - "Enabled": "", + "Overridden by {{type}} version": "", + "Not Loaded": "", "Disabled": "", + "Loaded": "", "Enable": "", "Save & Apply": "Sauvegarder et appliquer", - "Unknown error": "", - "Failed to delete plugin: {{ msg }}": "", "Deleting plugin {{ itemName }}...": "", "Cancelled deletion of {{ itemName }}.": "Suppression de {{ itemName }} annulée.", "Deleted plugin {{ itemName }}.": "", "Error deleting plugin {{ itemName }}.": "", - "By": "", "Delete Plugin": "", + "By": "", + "This plugin is not currently loaded because a \"{{type}}\" version is being used instead.": "", "Are you sure you want to delete this plugin?": "", "Uh-oh! Something went wrong.": "Uh-oh ! Quelque chose s'est mal passé.", "Error loading {{ routeName }}": "", @@ -197,7 +204,6 @@ "Clusters successfully set up!": "Clusters configurés avec succès !", "Finish": "Terminer", "Only warnings ({{ numWarnings }})": "Seulement les avertissements ({{ numWarnings }})", - "Type": "Type", "Reason": "Motif", "Last Seen": "Dernière vue", "Offline": "Hors ligne", diff --git a/frontend/src/i18n/locales/hi/translation.json b/frontend/src/i18n/locales/hi/translation.json index 390ac025dfe..2407019d2e1 100644 --- a/frontend/src/i18n/locales/hi/translation.json +++ b/frontend/src/i18n/locales/hi/translation.json @@ -90,20 +90,27 @@ "View all notifications": "सभी सूचनाएँ देखें", "Unknown": "अज्ञात", "Plugins": "प्लगइन्स", + "Multiple versions": "", "Description": "विवरण", + "Type": "प्रकार", + "Development": "", + "User-installed": "", + "Shipped": "", + "This plugin is not compatible with this version of Headlamp": "", "Incompatible": "असंगत", - "Enabled": "सक्षम", + "Overridden by {{type}} version": "", + "Not Loaded": "", "Disabled": "अक्षम", + "Loaded": "", "Enable": "सक्षम करें", "Save & Apply": "सहेजें और लागू करें", - "Unknown error": "", - "Failed to delete plugin: {{ msg }}": "", "Deleting plugin {{ itemName }}...": "", "Cancelled deletion of {{ itemName }}.": "{{ itemName }} के हटाने को रद्द किया गया।", "Deleted plugin {{ itemName }}.": "", "Error deleting plugin {{ itemName }}.": "", - "By": "द्वारा", "Delete Plugin": "प्लगइन हटाएँ", + "By": "द्वारा", + "This plugin is not currently loaded because a \"{{type}}\" version is being used instead.": "", "Are you sure you want to delete this plugin?": "क्या आप वाकई इस प्लगइन को हटाना चाहते हैं?", "Uh-oh! Something went wrong.": "अरे! कुछ गलत हो गया।", "Error loading {{ routeName }}": "{{ routeName }} लोड करने में त्रुटि", @@ -197,7 +204,6 @@ "Clusters successfully set up!": "क्लस्टर सफलतापूर्वक सेट अप किए गए!", "Finish": "समाप्त करें", "Only warnings ({{ numWarnings }})": "केवल चेतावनियाँ ({{ numWarnings }})", - "Type": "प्रकार", "Reason": "कारण", "Last Seen": "अंतिम बार देखा गया", "Offline": "ऑफलाइन", diff --git a/frontend/src/i18n/locales/it/translation.json b/frontend/src/i18n/locales/it/translation.json index 5d92af2b0c9..e7bf1d2eaed 100644 --- a/frontend/src/i18n/locales/it/translation.json +++ b/frontend/src/i18n/locales/it/translation.json @@ -90,20 +90,27 @@ "View all notifications": "Visualizza tutte le notifiche", "Unknown": "Sconosciuto", "Plugins": "Estensioni", + "Multiple versions": "", "Description": "Descrizione", + "Type": "Tipo", + "Development": "", + "User-installed": "", + "Shipped": "", + "This plugin is not compatible with this version of Headlamp": "", "Incompatible": "Incompatibile", - "Enabled": "Abilitato", + "Overridden by {{type}} version": "", + "Not Loaded": "", "Disabled": "Disabilitato", + "Loaded": "", "Enable": "Abilita", "Save & Apply": "Salva e Applica", - "Unknown error": "", - "Failed to delete plugin: {{ msg }}": "", "Deleting plugin {{ itemName }}...": "", "Cancelled deletion of {{ itemName }}.": "Eliminazione di {{ itemName }} annullata.", "Deleted plugin {{ itemName }}.": "", "Error deleting plugin {{ itemName }}.": "", - "By": "Di", "Delete Plugin": "Elimina Estensione", + "By": "Di", + "This plugin is not currently loaded because a \"{{type}}\" version is being used instead.": "", "Are you sure you want to delete this plugin?": "Sei sicuro di voler eliminare questa estensione?", "Uh-oh! Something went wrong.": "Ops! Qualcosa è andato storto.", "Error loading {{ routeName }}": "Errore durante il caricamento di {{ routeName }}", @@ -197,7 +204,6 @@ "Clusters successfully set up!": "Cluster configurati con successo!", "Finish": "Completa", "Only warnings ({{ numWarnings }})": "Solo avvertenze ({{ numWarnings }})", - "Type": "Tipo", "Reason": "Motivo", "Last Seen": "Ultima Visualizzazione", "Offline": "Offline", diff --git a/frontend/src/i18n/locales/ja/translation.json b/frontend/src/i18n/locales/ja/translation.json index b493d11915c..b45cef26be0 100644 --- a/frontend/src/i18n/locales/ja/translation.json +++ b/frontend/src/i18n/locales/ja/translation.json @@ -90,20 +90,27 @@ "View all notifications": "すべての通知を表示", "Unknown": "不明", "Plugins": "プラグイン", + "Multiple versions": "", "Description": "説明", + "Type": "タイプ", + "Development": "", + "User-installed": "", + "Shipped": "", + "This plugin is not compatible with this version of Headlamp": "", "Incompatible": "互換性なし", - "Enabled": "有効", + "Overridden by {{type}} version": "", + "Not Loaded": "", "Disabled": "無効", + "Loaded": "", "Enable": "有効にする", "Save & Apply": "保存して適用", - "Unknown error": "", - "Failed to delete plugin: {{ msg }}": "", "Deleting plugin {{ itemName }}...": "", "Cancelled deletion of {{ itemName }}.": "{{ itemName }} の削除をキャンセルしました。", "Deleted plugin {{ itemName }}.": "", "Error deleting plugin {{ itemName }}.": "", - "By": "作成者", "Delete Plugin": "プラグインの削除", + "By": "作成者", + "This plugin is not currently loaded because a \"{{type}}\" version is being used instead.": "", "Are you sure you want to delete this plugin?": "このプラグインを削除してもよろしいですか?", "Uh-oh! Something went wrong.": "あれっ!何か問題が発生しました。", "Error loading {{ routeName }}": "{{ routeName }} の読み込み中にエラーが発生しました", @@ -197,7 +204,6 @@ "Clusters successfully set up!": "クラスターのセットアップが成功しました!", "Finish": "完了", "Only warnings ({{ numWarnings }})": "警告のみ ({{ numWarnings }})", - "Type": "タイプ", "Reason": "理由", "Last Seen": "最終確認", "Offline": "オフライン", diff --git a/frontend/src/i18n/locales/ko/translation.json b/frontend/src/i18n/locales/ko/translation.json index 155f470ecd8..66f47bc06be 100644 --- a/frontend/src/i18n/locales/ko/translation.json +++ b/frontend/src/i18n/locales/ko/translation.json @@ -90,20 +90,27 @@ "View all notifications": "모든 알림 보기", "Unknown": "알 수 없음", "Plugins": "플러그인", + "Multiple versions": "", "Description": "설명", + "Type": "유형", + "Development": "", + "User-installed": "", + "Shipped": "", + "This plugin is not compatible with this version of Headlamp": "", "Incompatible": "호환되지 않음", - "Enabled": "사용함", + "Overridden by {{type}} version": "", + "Not Loaded": "", "Disabled": "사용 안 함", + "Loaded": "", "Enable": "사용", "Save & Apply": "저장 및 적용", - "Unknown error": "", - "Failed to delete plugin: {{ msg }}": "", "Deleting plugin {{ itemName }}...": "", "Cancelled deletion of {{ itemName }}.": "{{ itemName }} 삭제 취소됨.", "Deleted plugin {{ itemName }}.": "", "Error deleting plugin {{ itemName }}.": "", - "By": "작성자", "Delete Plugin": "플러그인 삭제", + "By": "작성자", + "This plugin is not currently loaded because a \"{{type}}\" version is being used instead.": "", "Are you sure you want to delete this plugin?": "이 플러그인을 삭제하시겠습니까?", "Uh-oh! Something went wrong.": "앗! 문제가 발생했습니다.", "Error loading {{ routeName }}": "{{ routeName }} 로드를 실패했습니다.", @@ -197,7 +204,6 @@ "Clusters successfully set up!": "클러스터 설정 완료!", "Finish": "완료", "Only warnings ({{ numWarnings }})": "경고만 보기 ({{ numWarnings }})", - "Type": "유형", "Reason": "이유", "Last Seen": "마지막 확인", "Offline": "오프라인", diff --git a/frontend/src/i18n/locales/pt/translation.json b/frontend/src/i18n/locales/pt/translation.json index d58727edf78..43d486020d2 100644 --- a/frontend/src/i18n/locales/pt/translation.json +++ b/frontend/src/i18n/locales/pt/translation.json @@ -90,20 +90,27 @@ "View all notifications": "Ver todas as notificações", "Unknown": "Desconhecido", "Plugins": "Plugins", + "Multiple versions": "", "Description": "Descrição", + "Type": "Tipo", + "Development": "", + "User-installed": "", + "Shipped": "", + "This plugin is not compatible with this version of Headlamp": "", "Incompatible": "Incompatível", - "Enabled": "Activado", + "Overridden by {{type}} version": "", + "Not Loaded": "", "Disabled": "Desactivado", + "Loaded": "", "Enable": "Activar", "Save & Apply": "Guardar & Aplicar", - "Unknown error": "", - "Failed to delete plugin: {{ msg }}": "", "Deleting plugin {{ itemName }}...": "", "Cancelled deletion of {{ itemName }}.": "A eliminação do item {{ itemName }} foi cancelada.", "Deleted plugin {{ itemName }}.": "", "Error deleting plugin {{ itemName }}.": "", - "By": "Por", "Delete Plugin": "Eliminar Plugin", + "By": "Por", + "This plugin is not currently loaded because a \"{{type}}\" version is being used instead.": "", "Are you sure you want to delete this plugin?": "Tem a certeza que deseja eliminar este plugin?", "Uh-oh! Something went wrong.": "Oh-oh! Algo correu mal.", "Error loading {{ routeName }}": "Erro ao carregar {{ routeName }}", @@ -197,7 +204,6 @@ "Clusters successfully set up!": "Clusters configurados com sucesso!", "Finish": "Terminar", "Only warnings ({{ numWarnings }})": "Só avisos ({{ numWarnings }})", - "Type": "Tipo", "Reason": "Razão", "Last Seen": "Visto últ. vez", "Offline": "Desconectado", diff --git a/frontend/src/i18n/locales/ta/translation.json b/frontend/src/i18n/locales/ta/translation.json index 12e1eef37af..def483a341b 100644 --- a/frontend/src/i18n/locales/ta/translation.json +++ b/frontend/src/i18n/locales/ta/translation.json @@ -90,20 +90,27 @@ "View all notifications": "அனைத்து அறிவிப்புகளையும் பார்", "Unknown": "தெரியாதது", "Plugins": "பிளக்-இன்கள்", + "Multiple versions": "", "Description": "விளக்கம்", + "Type": "வகை", + "Development": "", + "User-installed": "", + "Shipped": "", + "This plugin is not compatible with this version of Headlamp": "", "Incompatible": "பொருந்தாதது", - "Enabled": "இயக்கப்பட்டது", + "Overridden by {{type}} version": "", + "Not Loaded": "", "Disabled": "முடக்கப்பட்டது", + "Loaded": "", "Enable": "இயக்கு", "Save & Apply": "சேமித்து பயன்படுத்து", - "Unknown error": "", - "Failed to delete plugin: {{ msg }}": "", "Deleting plugin {{ itemName }}...": "", "Cancelled deletion of {{ itemName }}.": "{{ itemName }} உருப்படியை நீக்குவது ரத்து செய்யப்பட்டது.", "Deleted plugin {{ itemName }}.": "", "Error deleting plugin {{ itemName }}.": "", - "By": "மூலம்", "Delete Plugin": "பிளக்-இன் நீக்கு", + "By": "மூலம்", + "This plugin is not currently loaded because a \"{{type}}\" version is being used instead.": "", "Are you sure you want to delete this plugin?": "இந்த பிளக்-இனை நீக்க விரும்புகிறீர்களா?", "Uh-oh! Something went wrong.": "அய்யோ! ஏதோ பிழை நேர்ந்தது.", "Error loading {{ routeName }}": "{{ routeName }} ஏற்றுவதில் பிழை", @@ -197,7 +204,6 @@ "Clusters successfully set up!": "க்ளஸ்டர்கள் வெற்றிகரமாக அமைக்கப்பட்டன!", "Finish": "முடிக்கவும்", "Only warnings ({{ numWarnings }})": "எச்சரிக்கைகள் ({{ numWarnings }})", - "Type": "வகை", "Reason": "காரணம்", "Last Seen": "கடைசியாக பார்க்கப்பட்டது", "Offline": "ஆஃப்லைன்", diff --git a/frontend/src/i18n/locales/zh-tw/translation.json b/frontend/src/i18n/locales/zh-tw/translation.json index b2ffd523904..084008c5cf1 100644 --- a/frontend/src/i18n/locales/zh-tw/translation.json +++ b/frontend/src/i18n/locales/zh-tw/translation.json @@ -90,20 +90,27 @@ "View all notifications": "檢視所有通知", "Unknown": "未知", "Plugins": "外掛", + "Multiple versions": "", "Description": "描述", + "Type": "類型", + "Development": "", + "User-installed": "", + "Shipped": "", + "This plugin is not compatible with this version of Headlamp": "", "Incompatible": "不相容", - "Enabled": "已啟用", + "Overridden by {{type}} version": "", + "Not Loaded": "", "Disabled": "已停用", + "Loaded": "", "Enable": "啟用", "Save & Apply": "儲存並應用", - "Unknown error": "", - "Failed to delete plugin: {{ msg }}": "", "Deleting plugin {{ itemName }}...": "", "Cancelled deletion of {{ itemName }}.": "取消刪除 {{ itemName }}。", "Deleted plugin {{ itemName }}.": "", "Error deleting plugin {{ itemName }}.": "", - "By": "由", "Delete Plugin": "刪除外掛", + "By": "由", + "This plugin is not currently loaded because a \"{{type}}\" version is being used instead.": "", "Are you sure you want to delete this plugin?": "您確定要刪除此外掛嗎?", "Uh-oh! Something went wrong.": "哎呀!出現問題。", "Error loading {{ routeName }}": "讀取 {{ routeName }} 時出錯", @@ -197,7 +204,6 @@ "Clusters successfully set up!": "叢集設定成功!", "Finish": "完成", "Only warnings ({{ numWarnings }})": "僅警告 ({{ numWarnings }})", - "Type": "類型", "Reason": "原因", "Last Seen": "最後一次看到", "Offline": "離線", diff --git a/frontend/src/i18n/locales/zh/translation.json b/frontend/src/i18n/locales/zh/translation.json index 876c7ebffe7..892154702fc 100644 --- a/frontend/src/i18n/locales/zh/translation.json +++ b/frontend/src/i18n/locales/zh/translation.json @@ -90,20 +90,27 @@ "View all notifications": "查看所有通知", "Unknown": "未知", "Plugins": "插件", + "Multiple versions": "", "Description": "描述", + "Type": "类型", + "Development": "", + "User-installed": "", + "Shipped": "", + "This plugin is not compatible with this version of Headlamp": "", "Incompatible": "不兼容", - "Enabled": "已启用", + "Overridden by {{type}} version": "", + "Not Loaded": "", "Disabled": "已停用", + "Loaded": "", "Enable": "启用", "Save & Apply": "保存并应用", - "Unknown error": "", - "Failed to delete plugin: {{ msg }}": "", "Deleting plugin {{ itemName }}...": "", "Cancelled deletion of {{ itemName }}.": "取消刪除 {{ itemName }}。", "Deleted plugin {{ itemName }}.": "", "Error deleting plugin {{ itemName }}.": "", - "By": "由", "Delete Plugin": "刪除插件", + "By": "由", + "This plugin is not currently loaded because a \"{{type}}\" version is being used instead.": "", "Are you sure you want to delete this plugin?": "您确定要刪除此插件吗?", "Uh-oh! Something went wrong.": "哎呀!出现问题。", "Error loading {{ routeName }}": "读取 {{ routeName }} 时出错", @@ -197,7 +204,6 @@ "Clusters successfully set up!": "集群设置成功!", "Finish": "完成", "Only warnings ({{ numWarnings }})": "仅警告 ({{ numWarnings }})", - "Type": "类型", "Reason": "原因", "Last Seen": "最后一次看到", "Offline": "离线", diff --git a/frontend/src/lib/k8s/api/v1/pluginsApi.ts b/frontend/src/lib/k8s/api/v1/pluginsApi.ts index 1c973b9d6c7..2d3e9239b39 100644 --- a/frontend/src/lib/k8s/api/v1/pluginsApi.ts +++ b/frontend/src/lib/k8s/api/v1/pluginsApi.ts @@ -21,39 +21,31 @@ import { request } from './clusterRequests'; * 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. + * endpoint, targeting the plugin identified by its name and type. * The function handles the request asynchronously and returns a promise that * resolves when the deletion succeeds. * * @param name - The unique name of the plugin to delete. * This identifier is used to construct the URL for the DELETE request. + * @param type - Optional plugin type ('development' or 'user'). If specified, + * only that specific plugin location is checked. If omitted, the backend + * will check both locations in priority order. * - * @returns Resolves to the parsed response body if present; otherwise `undefined`. + * @returns A promise that resolves when the deletion is complete. * @throws {error} — 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)) + * // Call to delete a plugin named 'examplePlugin' from user-plugins + * deletePlugin('examplePlugin', 'user') + * .then(() => console.log('Plugin deleted successfully')) * .catch(error => console.error('Failed to delete plugin', error)); */ -export async function deletePlugin(name: string) { - const res = (await request( - `/plugins/${name}`, - { method: 'DELETE', headers: { ...getHeadlampAPIHeaders() } }, +export async function deletePlugin(name: string, type?: 'development' | 'user') { + const url = type ? `/plugins/${name}?type=${type}` : `/plugins/${name}`; + await request( + url, + { method: 'DELETE', headers: { ...getHeadlampAPIHeaders() }, isJSON: false }, false, - true - )) as any; - - // Handle real fetch Response - if (res && typeof res.ok === 'boolean' && typeof res.text === 'function') { - const text = await res.text().catch(() => ''); - if (!res.ok) { - throw new Error(text.trim() || `HTTP ${res.status}`); - } - return text ? JSON.parse(text) : undefined; - } - - // Otherwise request() already returned the parsed payload - return res; + false + ); } diff --git a/frontend/src/lib/router/index.tsx b/frontend/src/lib/router/index.tsx index 6907269cf07..4c763f181b1 100644 --- a/frontend/src/lib/router/index.tsx +++ b/frontend/src/lib/router/index.tsx @@ -923,7 +923,7 @@ const defaultRoutes: { [routeName: string]: Route } = { component: () => , }, pluginDetails: { - path: '/settings/plugins/:name', + path: '/settings/plugins/:name/:type?', exact: true, name: 'Plugin Details', sidebar: { diff --git a/frontend/src/plugin/index.ts b/frontend/src/plugin/index.ts index 3b5e5fc9743..a23ab67b750 100644 --- a/frontend/src/plugin/index.ts +++ b/frontend/src/plugin/index.ts @@ -195,16 +195,98 @@ export function filterSources( } /** - * Gives back updates settings from the backend. + * Apply priority-based plugin loading logic. * - * If there are new plugins, it includes the new ones with isEnabled=true. + * When multiple versions of the same plugin exist across different locations: + * - Priority order: development > user > shipped + * - Only the highest priority ENABLED version is loaded + * - If a higher priority version is disabled, the next enabled version is loaded + * - Lower priority versions are marked with isLoaded=false and overriddenBy info * - * If plugins are not there anymore in the backend list, - * then it removes them from the settings list of plugins. + * @param plugins List of all plugins from all locations + * @returns Plugins with isLoaded and overriddenBy fields set appropriately + */ +export function applyPluginPriority(plugins: PluginInfo[]): PluginInfo[] { + // Group plugins by name + const pluginsByName = new Map(); + + plugins.forEach(plugin => { + const existing = pluginsByName.get(plugin.name) || []; + existing.push(plugin); + pluginsByName.set(plugin.name, existing); + }); + + const result: PluginInfo[] = []; + + // Process each plugin name group + pluginsByName.forEach(versions => { + if (versions.length === 1) { + // Only one version exists, mark it as loaded if enabled + result.push({ + ...versions[0], + isLoaded: versions[0].isEnabled !== false, + }); + return; + } + + // Multiple versions exist - apply priority + const priorityOrder: Array<'development' | 'user' | 'shipped'> = [ + 'development', + 'user', + 'shipped', + ]; + + // Sort versions by priority (highest first) + const sortedVersions = versions.sort((a, b) => { + const aPriority = priorityOrder.indexOf(a.type || 'shipped'); + const bPriority = priorityOrder.indexOf(b.type || 'shipped'); + return aPriority - bPriority; + }); + + // Find the highest priority enabled version + let loadedVersion: PluginInfo | null = null; + + for (const version of sortedVersions) { + if (version.isEnabled !== false) { + loadedVersion = version; + break; + } + } + + // Mark each version appropriately + sortedVersions.forEach(version => { + if (loadedVersion && version === loadedVersion) { + // This is the version that will be loaded + result.push({ + ...version, + isLoaded: true, + }); + } else { + // This version is overridden by a higher priority version + result.push({ + ...version, + isLoaded: false, + overriddenBy: loadedVersion?.type, + }); + } + }); + }); + + return result; +} + +/** + * Updates settings packages based on what the backend provides. + * + * - For new plugins (not in settings), includes them with isEnabled=true + * - For existing plugins (in settings), preserves their isEnabled preference + * - Returns only plugins that exist in the backend list (automatically removing any that are gone) + * - Treats plugins with the same name but different types as separate entries + * - Each plugin is identified by name + type combination * * @param backendPlugins the list of plugins info from the backend. * @param settingsPlugins the list of plugins the settings already knows about. - * @returns plugin info for the settings. + * @returns plugin info for the settings (only includes plugins from backend). */ export function updateSettingsPackages( backendPlugins: PluginInfo[], @@ -212,27 +294,35 @@ export function updateSettingsPackages( ): PluginInfo[] { if (backendPlugins.length === 0) return []; + // Create a unique key for each plugin (name + type) + const getPluginKey = (plugin: PluginInfo) => `${plugin.name}@${plugin.type || 'unknown'}`; + const pluginsChanged = backendPlugins.length !== settingsPlugins.length || - backendPlugins.map(p => p.name + p.version).join('') !== - settingsPlugins.map(p => p.name + p.version).join(''); + backendPlugins.map(p => getPluginKey(p) + p.version).join('') !== + settingsPlugins.map(p => getPluginKey(p) + p.version).join(''); if (!pluginsChanged) { return settingsPlugins; } return backendPlugins.map(plugin => { - const index = settingsPlugins.findIndex(x => x.name === plugin.name); + // Find matching plugin by name AND type + const index = settingsPlugins.findIndex(x => x.name === plugin.name && x.type === plugin.type); + if (index === -1) { - // It's a new one settings doesn't know about so we do not enable it by default + // It's a new one settings doesn't know about, enable it by default return { ...plugin, isEnabled: true, }; } + + // Merge settings with backend info, preserving user's isEnabled preference return { ...settingsPlugins[index], ...plugin, + isEnabled: settingsPlugins[index].isEnabled, }; }); } @@ -327,11 +417,10 @@ async function fetchWithRetry( * Get the list of plugins, * download all the plugin source, * download all the plugin package.json files, - * ask app for permission secrets, - * filter the sources to execute, - * filter the incompatible plugins and plugins enabled in settings, - * execute the plugins, - * .initialize() plugins that register (not all do). + * apply priority-based filtering (dev > user > shipped), + * filter incompatible plugins and respect enable/disable settings, + * execute only the highest priority enabled version of each plugin, + * initialize() plugins that register. * * @param settingsPackages The packages settings knows about. * @param onSettingsChange Called when the plugins are different to what is in settings. @@ -347,9 +436,19 @@ export async function fetchAndExecutePlugins( const headers = addBackstageAuthHeaders(); - const pluginPaths = (await fetchWithRetry(`${getAppUrl()}plugins`, headers).then(resp => + // Backend now returns plugin metadata with path, type, and name + interface PluginMetadata { + path: string; + type: 'development' | 'user' | 'shipped'; + name: string; + } + + const pluginMetadataList = (await fetchWithRetry(`${getAppUrl()}plugins`, headers).then(resp => resp.json() - )) as string[]; + )) as PluginMetadata[]; + + // Extract paths for fetching plugin files + const pluginPaths = pluginMetadataList.map(metadata => metadata.path); const sourcesPromise = Promise.all( pluginPaths.map(path => @@ -360,7 +459,7 @@ export async function fetchAndExecutePlugins( ); const packageInfosPromise = await Promise.all( - pluginPaths.map(path => + pluginPaths.map((path, index) => fetch(`${getAppUrl()}${path}/package.json`, { headers: new Headers(headers) }).then(resp => { if (!resp.ok) { if (resp.status !== 404) { @@ -378,10 +477,16 @@ export async function fetchAndExecutePlugins( version: '0.0.0', author: 'unknown', description: '', + type: pluginMetadataList[index].type, + folderName: pluginMetadataList[index].name, }; } } - return resp.json(); + return resp.json().then(json => ({ + ...json, + type: pluginMetadataList[index].type, + folderName: pluginMetadataList[index].name, + })); }) ) ); @@ -390,37 +495,76 @@ export async function fetchAndExecutePlugins( const packageInfos = await packageInfosPromise; const permissionSecrets = await permissionSecretsPromise; - const updatedSettingsPackages = updateSettingsPackages(packageInfos, settingsPackages); - const settingsChanged = packageInfos.length !== settingsPackages.length; - if (settingsChanged) { - onSettingsChange(updatedSettingsPackages); - } + // Update settings to include all plugin versions (by name + type) + let updatedSettingsPackages = updateSettingsPackages(packageInfos, settingsPackages); + + // Apply priority-based loading logic + updatedSettingsPackages = applyPluginPriority(updatedSettingsPackages); + + // Notify settings of changes + onSettingsChange(updatedSettingsPackages); // Can set this to a semver version range like '>=0.8.0-alpha.3'. // '' means all versions. const compatibleHeadlampPluginVersion = '>=0.8.0-alpha.3'; - const { sourcesToExecute, incompatiblePlugins } = filterSources( - sources, - packageInfos, - isElectron(), - compatibleHeadlampPluginVersion, - updatedSettingsPackages - ); + // Mark incompatible plugins + const incompatiblePlugins: Record = {}; + updatedSettingsPackages = updatedSettingsPackages.map(plugin => { + const isCompatible = semver.satisfies( + semver.coerce(plugin.devDependencies?.['@kinvolk/headlamp-plugin']) || '', + compatibleHeadlampPluginVersion + ); + + if (!isCompatible) { + incompatiblePlugins[`${plugin.name}@${plugin.type}`] = plugin; + } + + return { + ...plugin, + isCompatible, + }; + }); if (Object.keys(incompatiblePlugins).length > 0) { onIncompatible(incompatiblePlugins); } - const packagesIncompatibleSet: PluginInfo[] = updatedSettingsPackages.map( - (plugin: PluginInfo) => { - return { - ...plugin, - isCompatible: !incompatiblePlugins[plugin.name], - }; + // Update settings with compatibility info + onSettingsChange(updatedSettingsPackages); + + // Filter to only execute plugins that should be loaded + // A plugin is executed if: + // 1. It's marked as isLoaded=true (highest priority enabled version) + // 2. It's compatible with this version of Headlamp + // 3. In app mode, it must be enabled + const pluginsToExecute = updatedSettingsPackages.filter(plugin => { + // Must be marked as the version to load + if (!plugin.isLoaded) { + return false; + } + + // Must be compatible + if (!plugin.isCompatible) { + return false; + } + + // In app mode, must be enabled + if (isElectron() && plugin.isEnabled === false) { + return false; } + + return true; + }); + + // Get indices of plugins to execute for matching with sources + const indicesToExecute = pluginsToExecute.map(plugin => + packageInfos.findIndex(p => p.name === plugin.name && p.type === plugin.type) ); - onSettingsChange(packagesIncompatibleSet); + + const sourcesToExecute = indicesToExecute.map(index => sources[index]); + const pluginPathsToExecute = indicesToExecute.map(index => pluginPaths[index]); + const packageInfosToExecute = indicesToExecute.map(index => packageInfos[index]); // Save references to the pluginRunCommand and desktopApiSend/Receive. // Plugins can use without worrying about modified global window.desktopApi. @@ -433,19 +577,22 @@ export async function fetchAndExecutePlugins( const isDevelopmentMode = process.env.NODE_ENV === 'development'; const consoleError = console.error; - const pluginsLoaded = updatedSettingsPackages.map(plugin => ({ - name: plugin.name, - version: plugin.version, - isEnabled: plugin.isEnabled, - })); + const pluginsLoaded = updatedSettingsPackages + .filter(plugin => plugin.isLoaded) + .map(plugin => ({ + name: plugin.name, + version: plugin.version, + isEnabled: plugin.isEnabled, + type: plugin.type, + })); const infoForRunningPlugins = sourcesToExecute .map((source, index) => { return getInfoForRunningPlugins({ source, - pluginPath: pluginPaths[index], - packageName: packageInfos[index].name, - packageVersion: packageInfos[index].version || '', + pluginPath: pluginPathsToExecute[index], + packageName: packageInfosToExecute[index].name, + packageVersion: packageInfosToExecute[index].version || '', permissionSecrets, handleError: handlePluginRunError, getAllowedPermissions: (pluginName, pluginPath, secrets): Record => { diff --git a/frontend/src/plugin/pluginsSlice.ts b/frontend/src/plugin/pluginsSlice.ts index 94e5c80f745..187134abb4f 100644 --- a/frontend/src/plugin/pluginsSlice.ts +++ b/frontend/src/plugin/pluginsSlice.ts @@ -53,6 +53,11 @@ export type PluginInfo = { * @see https://docs.npmjs.com/creating-a-package-json-file#required-name-and-version-fields */ name: string; + /** + * folderName is the actual folder name on disk (used for deletion). + * This may differ from the package.json name, especially for scoped packages. + */ + folderName?: string; /** * description text of the plugin from npm with same restrictions as package.json description * @see https://docs.npmjs.com/cli/v9/configuring-npm/package-json?v=true#description @@ -77,6 +82,23 @@ export type PluginInfo = { */ isEnabled?: boolean; + /** + * type indicates the source of the plugin: "development", "user", or "shipped" + */ + type?: 'development' | 'user' | 'shipped'; + + /** + * isLoaded indicates if this plugin version is actually loaded and executed. + * When multiple versions of the same plugin exist, only the highest priority enabled version is loaded. + */ + isLoaded?: boolean; + + /** + * overriddenBy indicates which higher-priority version is loaded instead of this one. + * Format: "type" (e.g., "development" or "user") + */ + overriddenBy?: 'development' | 'user' | 'shipped'; + /** * isCompatible is true when the plugin is compatible with this version of Headlamp. */ diff --git a/plugins/pluginctl/src/plugin-management.js b/plugins/pluginctl/src/plugin-management.js index 0195cd1d159..af367051b01 100644 --- a/plugins/pluginctl/src/plugin-management.js +++ b/plugins/pluginctl/src/plugin-management.js @@ -225,6 +225,15 @@ class PluginManager { try { const pluginsData = []; + // Check if folder exists, if not return empty array + if (!fs.existsSync(folder)) { + if (progressCallback) { + progressCallback({ type: 'success', message: 'No plugins folder found', data: [] }); + return; + } + return []; + } + // Read all entries in the specified folder const entries = fs.readdirSync(folder, { withFileTypes: true }); @@ -552,17 +561,17 @@ function checkValidPluginFolder(folder) { } /** - * Returns the default directory where Headlamp plugins are installed. + * Returns the default directory where Headlamp user-installed plugins are stored. * If the data path exists, it is used as the base directory. * Otherwise, the config path is used as the base directory. - * The 'plugins' subdirectory of the base directory is returned. + * The 'user-plugins' subdirectory of the base directory is returned. * - * @returns {string} The path to the default plugins directory. + * @returns {string} The path to the default user-plugins directory. */ function defaultPluginsDir() { const paths = envPaths('Headlamp', { suffix: '' }); const configDir = fs.existsSync(paths.data) ? paths.data : paths.config; - return path.join(configDir, 'plugins'); + return path.join(configDir, 'user-plugins'); } module.exports = { PluginManager, validateArchiveURL };