diff --git a/app/package-lock.json b/app/package-lock.json index 904d229898a..067cf7741c4 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "headlamp", - "version": "0.33.0", + "version": "0.34.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "headlamp", - "version": "0.33.0", + "version": "0.34.0", "dependencies": { "copyfiles": "^2.4.1", "cross-env": "^7.0.3", diff --git a/app/package.json b/app/package.json index 405bd077fe3..780df98555f 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "headlamp", - "version": "0.33.0", + "version": "0.34.0", "description": "Easy-to-use and extensible Kubernetes web UI", "main": "electron/main.js", "homepage": "https://github.com/kubernetes-sigs/headlamp/#readme", diff --git a/app/windows/chocolatey/headlamp.nuspec b/app/windows/chocolatey/headlamp.nuspec index e536b95ba8d..255abf847fa 100644 --- a/app/windows/chocolatey/headlamp.nuspec +++ b/app/windows/chocolatey/headlamp.nuspec @@ -3,7 +3,7 @@ headlamp - 0.33.0 + 0.34.0 https://github.com/kubernetes-sigs/headlamp/tree/main/app/windows/chocolatey Headlamp Kinvolk diff --git a/app/windows/chocolatey/tools/chocolateyinstall.ps1 b/app/windows/chocolatey/tools/chocolateyinstall.ps1 index e0f3677a100..bb1c23238bc 100644 --- a/app/windows/chocolatey/tools/chocolateyinstall.ps1 +++ b/app/windows/chocolatey/tools/chocolateyinstall.ps1 @@ -1,8 +1,8 @@ $ErrorActionPreference = 'Stop'; # stop on all errors $toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)" -$headlampVersion = '0.33.0' +$headlampVersion = '0.34.0' $url = "https://github.com/kubernetes-sigs/headlamp/releases/download/v${headlampVersion}/Headlamp-${headlampVersion}-win-x64.exe" -$checksum = 'a438191fcb08b82afcc4c5cb203808b341a8117dcd21d921d94f5f01bba19980' +$checksum = 'ce4d0a5c7566ed6c97042b6e3245b91e5a3d8e8169b669a88d4d9386db7650b3' $packageArgs = @{ packageName = $env:ChocolateyPackageName diff --git a/backend/cmd/headlamp.go b/backend/cmd/headlamp.go index 0907aab85bf..65354196286 100644 --- a/backend/cmd/headlamp.go +++ b/backend/cmd/headlamp.go @@ -290,6 +290,16 @@ func addPluginRoutes(config *HeadlampConfig, r *mux.Router) { r.PathPrefix("/plugins/").Handler(pluginHandler) + // Serve dev-plugins + devPluginDir := filepath.Join(filepath.Dir(config.PluginDir), "dev-plugins") + if _, err := os.Stat(devPluginDir); err == nil { + devPluginHandler := http.StripPrefix(config.BaseURL+"/dev-plugins/", http.FileServer(http.Dir(devPluginDir))) + if !config.UseInCluster { + devPluginHandler = serveWithNoCacheHeader(devPluginHandler) + } + r.PathPrefix("/dev-plugins/").Handler(devPluginHandler) + } + if config.StaticPluginDir != "" { staticPluginsHandler := http.StripPrefix(config.BaseURL+"/static-plugins/", http.FileServer(http.Dir(config.StaticPluginDir))) @@ -1524,8 +1534,6 @@ func (c *HeadlampConfig) getClusters() []Cluster { } for _, context := range contexts { - context := context - if context.Error != "" { clusters = append(clusters, Cluster{ Name: context.Name, @@ -1580,8 +1588,6 @@ func parseCustomNameClusters(contexts []kubeconfig.Context) ([]Cluster, []error) var setupErrors []error for _, context := range contexts { - context := context - info := context.KubeContext.Extensions["headlamp_info"] if info != nil { // Convert the runtime.Unknown object to a byte slice @@ -2042,8 +2048,6 @@ func (c *HeadlampConfig) updateCustomContextToCache(config *api.Config, clusterN } for _, context := range contexts { - context := context - // Remove the old context from the store if err := c.KubeConfigStore.RemoveContext(clusterName); err != nil { logger.Log(logger.LevelError, nil, err, "Removing context from the store") diff --git a/backend/cmd/headlamp_test.go b/backend/cmd/headlamp_test.go index d1dc0f58910..0aa60e35c24 100644 --- a/backend/cmd/headlamp_test.go +++ b/backend/cmd/headlamp_test.go @@ -248,7 +248,6 @@ func TestDynamicClusters(t *testing.T) { } for _, tc := range tests { - tc := tc t.Run(tc.name, func(t *testing.T) { cache := cache.New[interface{}]() kubeConfigStore := kubeconfig.NewContextStore() diff --git a/backend/cmd/stateless.go b/backend/cmd/stateless.go index 9f4defd33e7..a7d395c23cd 100644 --- a/backend/cmd/stateless.go +++ b/backend/cmd/stateless.go @@ -117,8 +117,6 @@ func (c *HeadlampConfig) handleStatelessReq(r *http.Request, kubeConfig string) } for _, context := range contexts { - context := context - info := context.KubeContext.Extensions["headlamp_info"] if info != nil { customObj, err := MarshalCustomObject(info, context.Name) diff --git a/backend/cmd/stateless_test.go b/backend/cmd/stateless_test.go index 4f59e1adebd..c05e9a38f6b 100644 --- a/backend/cmd/stateless_test.go +++ b/backend/cmd/stateless_test.go @@ -71,7 +71,6 @@ func TestStatelessClustersKubeConfig(t *testing.T) { } for _, tc := range tests { - tc := tc t.Run(tc.name, func(t *testing.T) { cache := cache.New[interface{}]() kubeConfigStore := kubeconfig.NewContextStore() @@ -127,7 +126,6 @@ func TestStatelessClusterApiRequest(t *testing.T) { } for _, tc := range tests { - tc := tc t.Run(tc.name, func(t *testing.T) { cache := cache.New[interface{}]() kubeConfigStore := kubeconfig.NewContextStore() diff --git a/backend/pkg/helm/charts.go b/backend/pkg/helm/charts.go index 2eed30dd4e8..a87fa6ad178 100644 --- a/backend/pkg/helm/charts.go +++ b/backend/pkg/helm/charts.go @@ -63,7 +63,6 @@ func listCharts(filter string, settings *cli.EnvSettings) ([]chartInfo, error) { index.AddRepo(name, indexFile, true) for _, chart := range index.All() { - chart := chart if filter != "" { if strings.Contains(strings.ToLower(chart.Name), strings.ToLower(filter)) { chartInfos = append(chartInfos, chartInfo{ diff --git a/backend/pkg/helm/repository.go b/backend/pkg/helm/repository.go index 3ee44a4137c..cad26a44f12 100644 --- a/backend/pkg/helm/repository.go +++ b/backend/pkg/helm/repository.go @@ -215,8 +215,6 @@ func listRepositories(settings *cli.EnvSettings) ([]repositoryInfo, error) { repositories := make([]repositoryInfo, 0, len(repoFile.Repositories)) for _, repo := range repoFile.Repositories { - repo := repo - repositories = append(repositories, repositoryInfo{ Name: repo.Name, URL: repo.URL, diff --git a/backend/pkg/helm/repository_test.go b/backend/pkg/helm/repository_test.go index 1a7be83b8ac..947109670a3 100644 --- a/backend/pkg/helm/repository_test.go +++ b/backend/pkg/helm/repository_test.go @@ -64,7 +64,6 @@ func checkRepoExists(t *testing.T, helmHandler *helm.Handler, repoName string) b require.NoError(t, err) for _, repo := range listRepoResponse.Repositories { - repo := repo if repo.Name == repoName { return true } @@ -186,7 +185,6 @@ func TestUpdateRepo(t *testing.T) { assert.NoError(t, err) for _, repo := range listRepoResponse.Repositories { - repo := repo if repo.Name == "headlamp_test_repo" { assert.Equal(t, "https://kubernetes-sigs-update-url.github.io/headlamp/", repo.URL) } diff --git a/backend/pkg/kubeconfig/watcher.go b/backend/pkg/kubeconfig/watcher.go index b24e8191628..adb31cd6ab9 100644 --- a/backend/pkg/kubeconfig/watcher.go +++ b/backend/pkg/kubeconfig/watcher.go @@ -68,8 +68,6 @@ func LoadAndWatchFiles(kubeConfigStore ContextStore, paths string, source int, i func addFilesToWatcher(watcher *fsnotify.Watcher, paths []string) { for _, path := range paths { - path := path - // if path is relative, make it absolute if !filepath.IsAbs(path) { absPath, err := filepath.Abs(path) diff --git a/backend/pkg/logger/logger_test.go b/backend/pkg/logger/logger_test.go index a24340fe8f1..f6664508c86 100644 --- a/backend/pkg/logger/logger_test.go +++ b/backend/pkg/logger/logger_test.go @@ -70,7 +70,6 @@ func TestLog(t *testing.T) { // Call the Log function for _, test := range tests { - test := test // Assign test to a local variable t.Run(test.name, func(t *testing.T) { logger.Log(test.level, test.str, test.err, test.msg) diff --git a/backend/pkg/plugins/plugins.go b/backend/pkg/plugins/plugins.go index ee78e069ad9..7484780d846 100644 --- a/backend/pkg/plugins/plugins.go +++ b/backend/pkg/plugins/plugins.go @@ -99,7 +99,8 @@ 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) { +// Returns (staticPlugins, devPlugins, catalogPlugins, error) with proper priority ordering. +func generateSeparatePluginPaths(staticPluginDir, pluginDir string) ([]string, []string, []string, error) { var pluginListURLStatic []string if staticPluginDir != "" { @@ -107,36 +108,83 @@ func generateSeparatePluginPaths(staticPluginDir, pluginDir string) ([]string, [ pluginListURLStatic, err = pluginBasePathListForDir(staticPluginDir, "static-plugins") if err != nil { - return nil, nil, err + return nil, nil, nil, err } } + // Get development plugins (highest priority) + // dev-plugins is a sibling directory to plugins, not a subdirectory + devPluginDir := filepath.Join(filepath.Dir(pluginDir), "dev-plugins") + pluginListURLDev, err := pluginBasePathListForDir(devPluginDir, "dev-plugins") + if err != nil && !os.IsNotExist(err) { + return nil, nil, nil, err + } + + // Get catalog/installed plugins pluginListURL, err := pluginBasePathListForDir(pluginDir, "plugins") if err != nil { - return nil, nil, err + return nil, nil, nil, err } - return pluginListURLStatic, pluginListURL, nil + return pluginListURLStatic, pluginListURLDev, pluginListURL, nil } // GeneratePluginPaths generates a concatenated list of plugin paths from the staticPluginDir and pluginDir. +// Priority order: dev-plugins > plugins > static-plugins (dev overrides catalog, catalog overrides static) func GeneratePluginPaths(staticPluginDir, pluginDir string) ([]string, error) { - pluginListURLStatic, pluginListURL, err := generateSeparatePluginPaths(staticPluginDir, pluginDir) + pluginListURLStatic, pluginListURLDev, pluginListURL, err := generateSeparatePluginPaths(staticPluginDir, pluginDir) if err != nil { return nil, err } - // Concatenate the static and user plugin lists. + // Create a map to track plugin names and ensure proper priority + pluginMap := make(map[string]string) + var finalPluginList []string + + // Add static plugins first (lowest priority) if pluginListURLStatic != nil { - pluginListURL = append(pluginListURLStatic, pluginListURL...) + for _, pluginPath := range pluginListURLStatic { + pluginName := getPluginNameFromPath(pluginPath) + pluginMap[pluginName] = pluginPath + } + } + + // Add catalog plugins (medium priority) - can override static + if pluginListURL != nil { + for _, pluginPath := range pluginListURL { + pluginName := getPluginNameFromPath(pluginPath) + pluginMap[pluginName] = pluginPath + } + } + + // Add dev plugins (highest priority) - can override both catalog and static + if pluginListURLDev != nil { + for _, pluginPath := range pluginListURLDev { + pluginName := getPluginNameFromPath(pluginPath) + pluginMap[pluginName] = pluginPath + } } - return pluginListURL, nil + // Convert map back to slice + for _, pluginPath := range pluginMap { + finalPluginList = append(finalPluginList, pluginPath) + } + + return finalPluginList, nil +} + +// getPluginNameFromPath extracts the plugin name from a plugin path +func getPluginNameFromPath(pluginPath string) string { + parts := strings.Split(pluginPath, "/") + if len(parts) >= 2 { + return parts[len(parts)-1] // Return the last part (plugin name) + } + return pluginPath } // ListPlugins lists the plugins in the static and user-added plugin directories. func ListPlugins(staticPluginDir, pluginDir string) error { - staticPlugins, userPlugins, err := generateSeparatePluginPaths(staticPluginDir, pluginDir) + staticPlugins, devPlugins, userPlugins, err := generateSeparatePluginPaths(staticPluginDir, pluginDir) if err != nil { logger.Log(logger.LevelError, nil, err, "listing plugins") return fmt.Errorf("listing plugins: %w", err) @@ -173,6 +221,18 @@ func ListPlugins(staticPluginDir, pluginDir string) error { fmt.Println("No static plugins found.") } + if len(devPlugins) > 0 { + devPluginDir := filepath.Join(pluginDir, "dev-plugins") + fmt.Printf("\nDevelopment Plugins (%s):\n", devPluginDir) + + for _, plugin := range devPlugins { + pluginName := getPluginName(filepath.Join(devPluginDir, plugin)) + fmt.Println(" -", pluginName, "(dev)") + } + } else { + fmt.Printf("No development plugins found.") + } + if len(userPlugins) > 0 { fmt.Printf("\nUser-added Plugins (%s):\n", pluginDir) diff --git a/backend/pkg/plugins/plugins_test.go b/backend/pkg/plugins/plugins_test.go index 71e26ff7122..97d76d26682 100644 --- a/backend/pkg/plugins/plugins_test.go +++ b/backend/pkg/plugins/plugins_test.go @@ -497,7 +497,6 @@ func TestDelete(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.pluginName, func(t *testing.T) { err := plugins.Delete(tt.pluginDir, tt.pluginName) if tt.expectErr { diff --git a/backend/pkg/portforward/internal_test.go b/backend/pkg/portforward/internal_test.go index 82d06723555..79bf4bdc38b 100644 --- a/backend/pkg/portforward/internal_test.go +++ b/backend/pkg/portforward/internal_test.go @@ -46,7 +46,6 @@ func TestPortforwardKeyGenerator(t *testing.T) { } for _, tt := range tests { - tt := tt testname := tt.name t.Run(testname, func(t *testing.T) { key := portforwardKeyGenerator(tt.p) diff --git a/frontend/src/components/App/PluginSettings/PluginSettings.tsx b/frontend/src/components/App/PluginSettings/PluginSettings.tsx index 0bab02bbc15..a315a910821 100644 --- a/frontend/src/components/App/PluginSettings/PluginSettings.tsx +++ b/frontend/src/components/App/PluginSettings/PluginSettings.tsx @@ -125,19 +125,22 @@ 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. */ - const [pluginChanges, setPluginChanges] = useState(() => - pluginArr.map((plugin: PluginInfo) => { + const [pluginChanges, setPluginChanges] = useState(() => { + console.log('PluginSettings: Received plugins:', pluginArr); + return pluginArr.map((plugin: PluginInfo) => { const [author, name] = plugin.name.includes('@') ? plugin.name.split(/\/(.+)/) : [null, plugin.name]; + console.log(`Plugin: ${plugin.name}, pluginType: ${plugin.pluginType}`); + return { ...plugin, displayName: name ?? plugin.name, origin: plugin.origin ?? author?.substring(1) ?? t('translation|Unknown'), }; - }) - ); + }); + }); /** * useEffect to control the rendering of the save button. @@ -198,8 +201,101 @@ export function PluginSettingsPure(props: PluginSettingsPureProps) { return ( <> + {/* Development Plugins Section */} + {pluginChanges.filter((plugin: PluginInfo) => plugin.pluginType === 'dev').length > 0 && ( + + } + > + }) => { + return ( + <> + + + {plugin.displayName} + + + {plugin.version} + + ); + }, + }, + { + header: t('translation|Description'), + accessorKey: 'description', + }, + { + header: t('translation|Origin'), + Cell: ({ row: { original: plugin } }: { row: MRT_Row }) => { + const url = plugin?.homepage || plugin?.repository?.url; + return plugin?.origin ? ( + url ? ( + {plugin.origin} + ) : ( + plugin?.origin + ) + ) : ( + t('translation|Development') + ); + }, + }, + // TODO: Fetch the plugin status from the plugin settings store + { + header: t('translation|Status'), + accessorFn: (plugin: PluginInfo) => { + if (plugin.isCompatible === false) { + return t('translation|Incompatible'); + } + return plugin.isEnabled ? t('translation|Enabled') : t('translation|Disabled'); + }, + }, + { + header: t('translation|Enable'), + accessorFn: (plugin: PluginInfo) => plugin.isEnabled, + Cell: ({ row: { original: plugin } }: { row: MRT_Row }) => { + if (!plugin.isCompatible || !isElectron()) { + return null; + } + return ( + switchChangeHanlder(plugin)} + color="primary" + name={plugin.name} + /> + ); + }, + }, + ] + // remove the enable column if we're not in app mode + .filter(el => !(el.header === t('translation|Enable') && !isElectron()))} + data={pluginChanges.filter((plugin: PluginInfo) => plugin.pluginType === 'dev')} + filterFunction={useFilterFunc(['.name'])} + /> + + )} + + {/* Catalog Plugins Section */} } + title={} >
!(el.header === t('translation|Enable') && !isElectron()))} - data={pluginChanges} + data={pluginChanges.filter( + (plugin: PluginInfo) => plugin.pluginType === 'catalog' || !plugin.pluginType + )} filterFunction={useFilterFunc(['.name'])} /> diff --git a/frontend/src/components/App/Settings/ClusterSelector.stories.tsx b/frontend/src/components/App/Settings/ClusterSelector.stories.tsx new file mode 100644 index 00000000000..c90cf765d91 --- /dev/null +++ b/frontend/src/components/App/Settings/ClusterSelector.stories.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createTheme, ThemeProvider } from '@mui/material/styles'; +import { configureStore } from '@reduxjs/toolkit'; +import { Meta, StoryFn } from '@storybook/react'; +import React from 'react'; +import { Provider } from 'react-redux'; +import ClusterSelector, { ClusterSelectorProps } from './ClusterSelector'; + +const theme = createTheme({ + palette: { + mode: 'light', + navbar: { + background: '#fff', + }, + }, +}); + +const getMockState = () => ({ + plugins: { loaded: true }, + theme: { + name: 'light', + logo: null, + palette: { + navbar: { + background: '#fff', + }, + }, + }, +}); + +export default { + title: 'Components/ClusterSelector', + component: ClusterSelector, +} as Meta; + +const Template: StoryFn = args => { + const store = configureStore({ + reducer: (state = getMockState()) => state, + preloadedState: getMockState(), + }); + + return ( + + + + + + ); +}; + +export const Default = Template.bind({}); +Default.args = { + currentCluster: 'dev', + clusters: ['dev', 'staging', 'prod'], +}; diff --git a/frontend/src/components/App/Settings/ClusterSelector.tsx b/frontend/src/components/App/Settings/ClusterSelector.tsx new file mode 100644 index 00000000000..8e46b837d03 --- /dev/null +++ b/frontend/src/components/App/Settings/ClusterSelector.tsx @@ -0,0 +1,54 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FormControl, InputLabel, MenuItem, Select } from '@mui/material'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHistory } from 'react-router-dom'; + +export interface ClusterSelectorProps { + currentCluster?: string; + clusters: string[]; +} + +const ClusterSelector: React.FC = ({ currentCluster = '', clusters }) => { + const history = useHistory(); + const { t } = useTranslation('glossary'); + + return ( + + {t('glossary|Cluster')} + + + ); +}; + +export default ClusterSelector; diff --git a/frontend/src/components/App/Settings/SettingsCluster.tsx b/frontend/src/components/App/Settings/SettingsCluster.tsx index 4d21e05177d..2498ae57353 100644 --- a/frontend/src/components/App/Settings/SettingsCluster.tsx +++ b/frontend/src/components/App/Settings/SettingsCluster.tsx @@ -17,11 +17,7 @@ import { Icon, InlineIcon } from '@iconify/react'; import Box from '@mui/material/Box'; import Chip from '@mui/material/Chip'; -import FormControl from '@mui/material/FormControl'; import IconButton from '@mui/material/IconButton'; -import InputLabel from '@mui/material/InputLabel'; -import MenuItem from '@mui/material/MenuItem'; -import Select from '@mui/material/Select'; import { useTheme } from '@mui/material/styles'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; @@ -46,6 +42,7 @@ import Link from '../../common/Link'; import Loader from '../../common/Loader'; import NameValueTable from '../../common/NameValueTable'; import SectionBox from '../../common/SectionBox'; +import ClusterSelector from './ClusterSelector'; import NodeShellSettings from './NodeShellSettings'; import { isValidNamespaceFormat } from './util'; @@ -61,39 +58,6 @@ function isValidClusterNameFormat(name: string) { return regex.test(name); } -interface ClusterSelectorProps { - currentCluster?: string; - clusters: string[]; -} - -function ClusterSelector(props: ClusterSelectorProps) { - const { currentCluster = '', clusters } = props; - const history = useHistory(); - const { t } = useTranslation('glossary'); - - return ( - - {t('glossary|Cluster')} - - - ); -} - export default function SettingsCluster() { const clusterConf = useClustersConf(); const clusters = Object.values(clusterConf || {}).map(cluster => cluster.name); @@ -360,7 +324,7 @@ export default function SettingsCluster() { } )} - + ); diff --git a/frontend/src/components/App/Settings/__snapshots__/ClusterSelector.Default.stories.storyshot b/frontend/src/components/App/Settings/__snapshots__/ClusterSelector.Default.stories.storyshot new file mode 100644 index 00000000000..fcf3f0ba019 --- /dev/null +++ b/frontend/src/components/App/Settings/__snapshots__/ClusterSelector.Default.stories.storyshot @@ -0,0 +1,61 @@ + +
+
+ +
+ + + + +
+
+
+ \ No newline at end of file diff --git a/frontend/src/components/App/Settings/__snapshots__/ClusterSelector.storyshots.test.tsx.snap b/frontend/src/components/App/Settings/__snapshots__/ClusterSelector.storyshots.test.tsx.snap new file mode 100644 index 00000000000..7cc907efb71 --- /dev/null +++ b/frontend/src/components/App/Settings/__snapshots__/ClusterSelector.storyshots.test.tsx.snap @@ -0,0 +1,63 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ClusterSelector > matches snapshot 1`] = ` +
+
+ +
+ + + + +
+
+
+`; diff --git a/frontend/src/components/App/Settings/__snapshots__/NodeShellSettings.Default.stories.storyshot b/frontend/src/components/App/Settings/__snapshots__/NodeShellSettings.Default.stories.storyshot index fab4c443774..fc5a1a0682e 100644 --- a/frontend/src/components/App/Settings/__snapshots__/NodeShellSettings.Default.stories.storyshot +++ b/frontend/src/components/App/Settings/__snapshots__/NodeShellSettings.Default.stories.storyshot @@ -81,7 +81,7 @@ class="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-colorPrimary MuiInputBase-formControl MuiInputBase-sizeSmall MuiInputBase-adornedEnd css-14f1a7d-MuiInputBase-root-MuiOutlinedInput-root" > parseCpu(c.resources?.requests?.cpu || '0')) + .reduce((a, b) => a + b, 0); + + const limit = pod.spec.containers + .map(c => parseCpu(c.resources?.limits?.cpu || '0')) + .reduce((a, b) => a + b, 0); + + const tooltipLines = []; + if (request > 0) { + const { value: rValue, unit: rUnit } = unparseCpu(String(request)); + const percentOfRequest = ((cpu / request) * 100).toFixed(1); + tooltipLines.push( + t('Request') + + `: ${percentOfRequest}% (${aValue} ${aUnit}/${rValue} ${rUnit})` + ); + } + if (limit > 0) { + const { value: lValue, unit: lUnit } = unparseCpu(String(limit)); + const percentOfLimit = ((cpu / limit) * 100).toFixed(1); + tooltipLines.push( + t('Limit') + `: ${percentOfLimit}% (${aValue} ${aUnit}/${lValue} ${lUnit})` + ); + } + + return ( + + {`${aValue} ${aUnit}`} + {tooltipLines.length > 0 && ( + + {tooltipLines.join('\n')} + + )} + + ); }, getValue: (pod: Pod) => getCpuUsage(pod) ?? 0, }, @@ -259,9 +294,43 @@ export function PodListRenderer(props: PodListProps) { render: (pod: Pod) => { const memory = getMemoryUsage(pod); if (memory === undefined) return; - const { value, unit } = unparseRam(memory); - - return `${value} ${unit}`; + const { value: aValue, unit: aUnit } = unparseRam(memory); + + const request = pod.spec.containers + .map(c => parseRam(c.resources?.requests?.memory || '0')) + .reduce((a, b) => a + b, 0); + + const limit = pod.spec.containers + .map(c => parseRam(c.resources?.limits?.memory || '0')) + .reduce((a, b) => a + b, 0); + + const tooltipLines = []; + if (request > 0) { + const { value: rValue, unit: rUnit } = unparseRam(request); + const percentOfRequest = ((memory / request) * 100).toFixed(1); + tooltipLines.push( + t('Request') + + `: ${percentOfRequest}% (${aValue} ${aUnit}/${rValue} ${rUnit})` + ); + } + if (limit > 0) { + const { value: lValue, unit: lUnit } = unparseRam(limit); + const percentOfLimit = ((memory / limit) * 100).toFixed(1); + tooltipLines.push( + t('Limit') + `: ${percentOfLimit}% (${aValue} ${aUnit}/${lValue} ${lUnit})` + ); + } + + return ( + + {`${aValue} ${aUnit}`} + {tooltipLines.length > 0 && ( + + {tooltipLines.join('\n')} + + )} + + ); }, getValue: (pod: Pod) => getMemoryUsage(pod) ?? 0, }, diff --git a/frontend/src/components/pod/__snapshots__/PodList.Items.stories.storyshot b/frontend/src/components/pod/__snapshots__/PodList.Items.stories.storyshot index d7f322ef433..e82e6524bb2 100644 --- a/frontend/src/components/pod/__snapshots__/PodList.Items.stories.storyshot +++ b/frontend/src/components/pod/__snapshots__/PodList.Items.stories.storyshot @@ -1124,12 +1124,28 @@
- 16.32 m +
+ + 16.32 m + +
- 46.4 Mi +
+ + 46.4 Mi + +
(typeof arg === 'object' ? JSON.stringify(arg) : String(arg))), + }); + } +} + export function getRoute(routeName: string) { + if (!routeName) { + debugLog('error', 'routeName is undefined/null/empty in getRoute'); + return undefined; + } + let routeKey = routeName; for (const key in defaultRoutes) { - if (key.toLowerCase() === routeName.toLowerCase()) { - // if (key !== routeName) { - // console.warn(`Route name ${routeName} and ${key} are not matching`); - // } - routeKey = key; - break; + if (!key || typeof key !== 'string') { + debugLog('warn', 'Invalid key in defaultRoutes:', key); + continue; + } + + try { + if (key.toLowerCase() === routeName.toLowerCase()) { + routeKey = key; + break; + } + } catch (error) { + debugLog( + 'error', + 'Error in toLowerCase comparison:', + error, + 'key:', + key, + 'routeName:', + routeName + ); } } - return defaultRoutes[routeKey]; + + const route = defaultRoutes[routeKey]; + return route; } /** @@ -1026,19 +1065,48 @@ export interface RouteURLProps { } export function createRouteURL(routeName: string, params: RouteURLProps = {}) { + if (!routeName) { + debugLog('error', 'routeName is undefined/null/empty in createRouteURL'); + return '/'; + } + + // Additional validation to ensure routeName is a non-empty string + if (typeof routeName !== 'string' || routeName.trim() === '') { + debugLog('error', 'routeName is not a valid string:', routeName, 'type:', typeof routeName); + return '/'; + } + const storeRoutes = store.getState().routes.routes; // First try to find by name - const matchingStoredRouteByName = - storeRoutes && - Object.entries(storeRoutes).find( - ([, route]) => route.name?.toLowerCase() === routeName.toLowerCase() - )?.[1]; + let matchingStoredRouteByName; + try { + matchingStoredRouteByName = + storeRoutes && + Object.entries(storeRoutes).find(([, route]) => { + if (!route?.name || typeof route.name !== 'string') { + return false; + } + return route.name.toLowerCase() === routeName.toLowerCase(); + })?.[1]; + } catch (error) { + debugLog('error', 'Error in matchingStoredRouteByName:', error); + } // Then try to find by path - const matchingStoredRouteByPath = - storeRoutes && - Object.entries(storeRoutes).find(([key]) => key.toLowerCase() === routeName.toLowerCase())?.[1]; + let matchingStoredRouteByPath; + try { + matchingStoredRouteByPath = + storeRoutes && + Object.entries(storeRoutes).find(([key]) => { + if (!key || typeof key !== 'string') { + return false; + } + return key.toLowerCase() === routeName.toLowerCase(); + })?.[1]; + } catch (error) { + debugLog('error', 'Error in matchingStoredRouteByPath:', error); + } if (matchingStoredRouteByPath && !matchingStoredRouteByName) { console.warn( @@ -1050,16 +1118,25 @@ export function createRouteURL(routeName: string, params: RouteURLProps = {}) { const route = matchingStoredRouteByName || matchingStoredRouteByPath || getRoute(routeName); if (!route) { + debugLog('error', 'No route found for routeName:', routeName); + return ''; + } + + if (!route.path) { + debugLog('error', 'Route found but has no path:', route); return ''; } let cluster = params.cluster; + if (!cluster && getRouteUseClusterURL(route)) { cluster = getClusterPathParam(); if (!cluster) { + debugLog('warn', 'No cluster found, returning /'); return '/'; } } + const fullParams = { selected: undefined, ...params, @@ -1072,11 +1149,19 @@ export function createRouteURL(routeName: string, params: RouteURLProps = {}) { // @todo: Remove this hack once we support redirection in routes if (routeName === 'settingsCluster') { - return `/settings/cluster?c=${fullParams.cluster}`; + const settingsUrl = `/settings/cluster?c=${fullParams.cluster}`; + return settingsUrl; } const url = getRoutePath(route); - return generatePath(url, fullParams); + + try { + const finalUrl = generatePath(url, fullParams); + return finalUrl; + } catch (error) { + debugLog('error', 'Error in generatePath:', error, 'url:', url, 'fullParams:', fullParams); + return url; // fallback to basic path + } } export function getDefaultRoutes() { diff --git a/frontend/src/plugin/index.ts b/frontend/src/plugin/index.ts index aceab1a4571..7c9a91e4a6f 100644 --- a/frontend/src/plugin/index.ts +++ b/frontend/src/plugin/index.ts @@ -221,28 +221,29 @@ export function updateSettingsPackages( ): PluginInfo[] { if (backendPlugins.length === 0) return []; - const pluginsChanged = - backendPlugins.length !== settingsPlugins.length || - backendPlugins.map(p => p.name + p.version).join('') !== - settingsPlugins.map(p => p.name + p.version).join(''); - - if (!pluginsChanged) { - return settingsPlugins; - } - + // Always update settings with the latest plugin info (including pluginType) + // even if no plugins were added/removed return backendPlugins.map(plugin => { const index = settingsPlugins.findIndex(x => x.name === plugin.name); if (index === -1) { // It's a new one settings doesn't know about so we do not enable it by default - return { + const newPlugin = { ...plugin, isEnabled: true, }; + console.log( + `updateSettingsPackages: New plugin ${plugin.name} with type: ${plugin.pluginType}` + ); + return newPlugin; } - return { + const updatedPlugin = { ...settingsPlugins[index], ...plugin, }; + console.log( + `updateSettingsPackages: Updated plugin ${plugin.name} with type: ${updatedPlugin.pluginType}` + ); + return updatedPlugin; }); } @@ -333,15 +334,52 @@ export async function fetchAndExecutePlugins( ' by running "headlamp-plugin extract" again.' + ' Please use headlamp-plugin >= 0.8.0' ); + // For missing package.json, determine type from path + let pluginType: 'dev' | 'catalog' | 'static' = 'static'; + if (path.startsWith('dev-plugins/')) { + pluginType = 'dev'; + } else if (path.startsWith('plugins/')) { + // All plugins in plugins/ directory are catalog plugins + pluginType = 'catalog'; + } else if (path.startsWith('.plugins/') || path.startsWith('static-plugins/')) { + // Bundled/shipped plugins are static plugins + pluginType = 'static'; + } return { name: path.split('/').slice(-1)[0], version: '0.0.0', author: 'unknown', description: '', + pluginType, + artifacthub: pluginType !== 'dev', // All plugins except dev plugins come from artifacthub }; } } - return resp.json(); + return resp.json().then((packageJson: any) => { + // Determine plugin type based on path structure + let pluginType: 'dev' | 'catalog' | 'static' = 'static'; + + if (path.startsWith('dev-plugins/')) { + pluginType = 'dev'; + } else if (path.startsWith('plugins/')) { + // All plugins in the plugins/ directory are catalog plugins + pluginType = 'catalog'; + } else if (path.startsWith('.plugins/') || path.startsWith('static-plugins/')) { + // Bundled/shipped plugins are static plugins + pluginType = 'static'; + } + + const pluginInfo = { + ...packageJson, + pluginType, + // Set artifacthub property - catalog and static plugins come from artifacthub + artifacthub: pluginType !== 'dev', + }; + console.log( + `Plugin loaded: ${packageJson.name} with type: ${pluginType} (path: ${path})` + ); + return pluginInfo; + }); }) ) ); @@ -351,8 +389,18 @@ export async function fetchAndExecutePlugins( const permissionSecrets = await permissionSecretsPromise; const updatedSettingsPackages = updateSettingsPackages(packageInfos, settingsPackages); + console.log( + 'updatedSettingsPackages:', + updatedSettingsPackages.map(p => ({ name: p.name, pluginType: p.pluginType })) + ); + + // Check if settings changed (new plugins added/removed) to decide whether to call onSettingsChange early const settingsChanged = packageInfos.length !== settingsPackages.length; if (settingsChanged) { + console.log( + 'First onSettingsChange call with:', + updatedSettingsPackages.map(p => ({ name: p.name, pluginType: p.pluginType })) + ); onSettingsChange(updatedSettingsPackages); } @@ -380,6 +428,10 @@ export async function fetchAndExecutePlugins( }; } ); + console.log( + 'Second onSettingsChange call with:', + packagesIncompatibleSet.map(p => ({ name: p.name, pluginType: p.pluginType })) + ); onSettingsChange(packagesIncompatibleSet); // Save references to the pluginRunCommand and desktopApiSend/Receive. diff --git a/frontend/src/plugin/pluginsSlice.ts b/frontend/src/plugin/pluginsSlice.ts index 94e5c80f745..a46e7ef7828 100644 --- a/frontend/src/plugin/pluginsSlice.ts +++ b/frontend/src/plugin/pluginsSlice.ts @@ -82,6 +82,14 @@ export type PluginInfo = { */ isCompatible?: boolean; + /** + * pluginType indicates the source type of the plugin. + * 'dev' for development plugins from dev-plugins directory + * 'catalog' for plugins from the plugin catalog (plugins directory) + * 'static' for static plugins + */ + pluginType?: 'dev' | 'catalog' | 'static'; + version?: string; // unused by PluginSettings author?: string; // unused by PluginSettings /** diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 407747906b5..d3a33184f43 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -32,6 +32,10 @@ export default defineConfig({ target: 'http://localhost:4466', changeOrigin: true, }, + '/dev-plugins': { + target: 'http://localhost:4466', + changeOrigin: true, + }, }, cors: true, }, diff --git a/plugins/headlamp-plugin/bin/headlamp-plugin.js b/plugins/headlamp-plugin/bin/headlamp-plugin.js index a7f37448135..4e2972157e6 100755 --- a/plugins/headlamp-plugin/bin/headlamp-plugin.js +++ b/plugins/headlamp-plugin/bin/headlamp-plugin.js @@ -20,7 +20,7 @@ 'use strict'; const crypto = require('crypto'); -const fs = require('fs-extra'); +const fs = require('fs'); const envPaths = require('env-paths'); const os = require('os'); const path = require('path'); @@ -69,9 +69,9 @@ function create(name, link) { console.log(`Creating folder :${dstFolder}:`); - fs.copySync(templateFolder, dstFolder, { - errorOnExist: true, - overwrite: false, + fs.cpSync(templateFolder, dstFolder, { + recursive: true, + force: false, }); function replaceFileVariables(path) { @@ -169,7 +169,9 @@ function extract(pluginPackagesPath, outputPlugins, logSteps = true) { const folderName = trimmedPath.split(path.sep).splice(-1)[0]; const plugName = path.join(outputPlugins, folderName); - fs.ensureDirSync(plugName); + if (!fs.existsSync(plugName)) { + fs.mkdirSync(plugName, { recursive: true }); + } const files = fs.readdirSync(distPath); files.forEach(file => { @@ -201,7 +203,9 @@ function extract(pluginPackagesPath, outputPlugins, logSteps = true) { const distPath = path.join(pluginPackagesPath, folder.name, 'dist'); const plugName = path.join(outputPlugins, folder.name); - fs.ensureDirSync(plugName); + if (!fs.existsSync(plugName)) { + fs.mkdirSync(plugName, { recursive: true }); + } const files = fs.readdirSync(distPath); files.forEach(file => { @@ -235,7 +239,7 @@ function extract(pluginPackagesPath, outputPlugins, logSteps = true) { */ async function calculateChecksum(filePath) { try { - const fileBuffer = await fs.readFile(filePath); + const fileBuffer = await fs.promises.readFile(filePath); const hashSum = crypto.createHash('sha256'); hashSum.update(fileBuffer); const hex = hashSum.digest('hex'); @@ -290,7 +294,7 @@ async function copyExtraDistFiles(packagePath = '.') { const sourceStats = fs.statSync(sourcePath); if (sourceStats.isDirectory()) { console.log(`Copying extra directory "${sourcePath}" to "${targetPath}"`); - fs.copySync(sourcePath, targetPath); + fs.cpSync(sourcePath, targetPath, { recursive: true }); } else { console.log(`Copying extra file "${sourcePath}" to "${targetPath}"`); fs.copyFileSync(sourcePath, targetPath); @@ -313,7 +317,7 @@ async function copyExtraDistFiles(packagePath = '.') { * @param {string} pluginDir - path to the plugin package. * @param {string} outputDir - folder where the tarball is placed. * - * @returns {0 | 1} Exit code, where 0 is success, 1 is failure. + * @returns {Promise<0 | 1>} Exit code, where 0 is success, 1 is failure. */ async function createArchive(pluginDir, outputDir) { const pluginPath = path.resolve(pluginDir); @@ -324,7 +328,7 @@ async function createArchive(pluginDir, outputDir) { // Extract name + version from plugin's package.json const packageJsonPath = path.join(pluginPath, 'package.json'); - let packageJson = ''; + let packageJson = {}; try { packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); } catch (e) { @@ -389,9 +393,9 @@ async function createArchive(pluginDir, outputDir) { */ async function start() { /** - * Copies the built plugin to the app config folder ~/.config/Headlamp/plugins/ + * Copies the built plugin to the app config folder ~/.config/Headlamp/dev-plugins/ * - * Adds a webpack config plugin for copying the folder. + * All plugins started with npm run start are placed in dev-plugins directory. */ async function copyToPluginsFolder(viteConfig) { const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')); @@ -402,6 +406,12 @@ async function start() { const paths = envPaths('Headlamp', { suffix: '' }); const configDir = fs.existsSync(paths.data) ? paths.data : paths.config; + // All plugins started with npm run start go to dev-plugins directory + await checkIfDevPlugin(packageName); + const targetDir = 'dev-plugins'; + + console.log(`Installing plugin to ${targetDir}/ directory (development priority)`); + const { viteStaticCopy } = await viteCopyPluginPromise; viteConfig.plugins.push( @@ -409,17 +419,31 @@ async function start() { targets: [ { src: './dist/*', - dest: path.join(configDir, 'plugins', packageName), + dest: path.join(configDir, targetDir, packageName), }, { src: './package.json', - dest: path.join(configDir, 'plugins', packageName), + dest: path.join(configDir, targetDir, packageName), }, ], }) ); } + /** + * Check if this plugin should be treated as a development plugin + * All plugins started with npm run start will be placed in dev-plugins directory + */ + async function checkIfDevPlugin(pluginName) { + try { + console.log(`✓ Plugin "${pluginName}" - using dev-plugins directory for development`); + return true; + } catch (error) { + console.warn('Warning: Error occurred, but defaulting to dev-plugins directory'); + return true; + } + } + /** * Inform if @kinvolk/headlamp-plugin is outdated. */