Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/cmd/headlamp.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ type clientConfig struct {
IsDynamicClusterEnabled bool `json:"isDynamicClusterEnabled"`
AllowKubeconfigChanges bool `json:"allowKubeconfigChanges"`
DefaultPodDebugImage string `json:"defaultPodDebugImage"`
DefaultLightTheme string `json:"defaultLightTheme,omitempty"`
DefaultDarkTheme string `json:"defaultDarkTheme,omitempty"`
ForceTheme string `json:"forceTheme,omitempty"`
Comment thread
illume marked this conversation as resolved.
}

type OauthConfig struct {
Expand Down Expand Up @@ -1975,6 +1978,9 @@ func (c *HeadlampConfig) getConfig(w http.ResponseWriter, r *http.Request) {
IsDynamicClusterEnabled: c.EnableDynamicClusters,
AllowKubeconfigChanges: c.AllowKubeconfigChanges,
DefaultPodDebugImage: c.PodDebugImage,
DefaultLightTheme: c.DefaultLightTheme,
DefaultDarkTheme: c.DefaultDarkTheme,
ForceTheme: c.ForceTheme,
}

if err := json.NewEncoder(w).Encode(&clientConfig); err != nil {
Expand Down
3 changes: 3 additions & 0 deletions backend/cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ func buildHeadlampCFG(conf *config.Config, kubeConfigStore kubeconfig.ContextSto
SessionTTL: conf.SessionTTL,
PodDebugImage: conf.PodDebugImage,
OidcUseCookie: conf.OidcUseCookie,
DefaultLightTheme: conf.DefaultLightTheme,
DefaultDarkTheme: conf.DefaultDarkTheme,
ForceTheme: conf.ForceTheme,
}
}

Expand Down
3 changes: 3 additions & 0 deletions backend/cmd/stateless.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,9 @@ func (c *HeadlampConfig) parseKubeConfig(w http.ResponseWriter, r *http.Request)
Clusters: contexts,
IsDynamicClusterEnabled: c.EnableDynamicClusters,
AllowKubeconfigChanges: c.AllowKubeconfigChanges,
DefaultLightTheme: c.DefaultLightTheme,
DefaultDarkTheme: c.DefaultDarkTheme,
ForceTheme: c.ForceTheme,
}

if err := json.NewEncoder(w).Encode(&clientConfig); err != nil {
Expand Down
18 changes: 18 additions & 0 deletions backend/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,18 @@ type Config struct {
// TLS config
TLSCertPath string `koanf:"tls-cert-path"`
TLSKeyPath string `koanf:"tls-key-path"`
// Theme config
DefaultLightTheme string `koanf:"default-light-theme"`
DefaultDarkTheme string `koanf:"default-dark-theme"`
ForceTheme string `koanf:"force-theme"`
Comment thread
guillaumebernard84 marked this conversation as resolved.
}

func (c *Config) warnRedundantThemeDefaults() {
if c.ForceTheme != "" && (c.DefaultLightTheme != "" || c.DefaultDarkTheme != "") {
logger.Log(logger.LevelWarn, nil, nil,
"force-theme is set together with default-light-theme/default-dark-theme, "+
"default themes will be ignored when force-theme is active")
}
}

func (c *Config) Validate() error {
Expand All @@ -103,6 +115,9 @@ func (c *Config) Validate() error {
"meant to be used in inCluster mode or with --oidc-use-cookie")
}

// Extracted to keep Validate's cognitive complexity within the linter limit.
c.warnRedundantThemeDefaults()

// OIDC TLS verification warning.
if c.OidcSkipTLSVerify {
logger.Log(logger.LevelWarn, nil, nil, "oidc-skip-tls-verify is set, this is not safe for production")
Expand Down Expand Up @@ -472,6 +487,9 @@ func addGeneralFlags(f *flag.FlagSet) {
f.Uint("port", defaultPort, "Port to listen from")
f.String("proxy-urls", "", "Allow proxy requests to specified URLs")
f.Bool("enable-helm", false, "Enable Helm operations")
f.String("default-light-theme", "", "Default theme to use when user prefers light mode")
f.String("default-dark-theme", "", "Default theme to use when user prefers dark mode")
f.String("force-theme", "", "Force a specific theme, overriding user preferences")
}

func addOIDCFlags(f *flag.FlagSet) {
Expand Down
120 changes: 120 additions & 0 deletions backend/pkg/config/config_theme_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package config_test

import (
"os"
"testing"

"github.com/kubernetes-sigs/headlamp/backend/pkg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestParseThemeConfiguration_DefaultLightTheme(t *testing.T) {
conf, err := config.Parse([]string{"go run ./cmd", "--default-light-theme=corporate-light"})
require.NoError(t, err)
require.NotNil(t, conf)

assert.Equal(t, "corporate-light", conf.DefaultLightTheme)
assert.Equal(t, "", conf.DefaultDarkTheme)
assert.Equal(t, "", conf.ForceTheme)
}

func TestParseThemeConfiguration_DefaultDarkTheme(t *testing.T) {
conf, err := config.Parse([]string{"go run ./cmd", "--default-dark-theme=corporate-dark"})
require.NoError(t, err)
require.NotNil(t, conf)

assert.Equal(t, "", conf.DefaultLightTheme)
assert.Equal(t, "corporate-dark", conf.DefaultDarkTheme)
assert.Equal(t, "", conf.ForceTheme)
}

func TestParseThemeConfiguration_BothDefaults(t *testing.T) {
conf, err := config.Parse([]string{
"go run ./cmd",
"--default-light-theme=corporate-light",
"--default-dark-theme=corporate-dark",
})
require.NoError(t, err)
require.NotNil(t, conf)

assert.Equal(t, "corporate-light", conf.DefaultLightTheme)
assert.Equal(t, "corporate-dark", conf.DefaultDarkTheme)
assert.Equal(t, "", conf.ForceTheme)
}

func TestParseThemeConfiguration_ForceTheme(t *testing.T) {
conf, err := config.Parse([]string{"go run ./cmd", "--force-theme=corporate-branded"})
require.NoError(t, err)
require.NotNil(t, conf)

assert.Equal(t, "", conf.DefaultLightTheme)
assert.Equal(t, "", conf.DefaultDarkTheme)
assert.Equal(t, "corporate-branded", conf.ForceTheme)
}

func TestParseThemeConfiguration_ForceWithDefaults(t *testing.T) {
conf, err := config.Parse([]string{
"go run ./cmd",
"--default-light-theme=light",
"--default-dark-theme=dark",
"--force-theme=corporate",
})
require.NoError(t, err)
require.NotNil(t, conf)

assert.Equal(t, "light", conf.DefaultLightTheme)
assert.Equal(t, "dark", conf.DefaultDarkTheme)
assert.Equal(t, "corporate", conf.ForceTheme)
}

func TestParseThemeConfiguration_FromEnv(t *testing.T) {
require.NoError(t, os.Setenv("HEADLAMP_CONFIG_DEFAULT_LIGHT_THEME", "env-light"))
require.NoError(t, os.Setenv("HEADLAMP_CONFIG_DEFAULT_DARK_THEME", "env-dark"))

defer func() {
require.NoError(t, os.Unsetenv("HEADLAMP_CONFIG_DEFAULT_LIGHT_THEME"))
require.NoError(t, os.Unsetenv("HEADLAMP_CONFIG_DEFAULT_DARK_THEME"))
}()

conf, err := config.Parse([]string{"go run ./cmd"})
require.NoError(t, err)
require.NotNil(t, conf)

assert.Equal(t, "env-light", conf.DefaultLightTheme)
assert.Equal(t, "env-dark", conf.DefaultDarkTheme)
}

func TestParseThemeConfiguration_ForceFromEnv(t *testing.T) {
require.NoError(t, os.Setenv("HEADLAMP_CONFIG_FORCE_THEME", "env-forced"))

defer func() { require.NoError(t, os.Unsetenv("HEADLAMP_CONFIG_FORCE_THEME")) }()

conf, err := config.Parse([]string{"go run ./cmd"})
require.NoError(t, err)
require.NotNil(t, conf)

assert.Equal(t, "env-forced", conf.ForceTheme)
}

func TestParseThemeConfiguration_ArgsOverrideEnv(t *testing.T) {
require.NoError(t, os.Setenv("HEADLAMP_CONFIG_DEFAULT_LIGHT_THEME", "env-theme"))

defer func() { require.NoError(t, os.Unsetenv("HEADLAMP_CONFIG_DEFAULT_LIGHT_THEME")) }()

conf, err := config.Parse([]string{"go run ./cmd", "--default-light-theme=arg-theme"})
require.NoError(t, err)
require.NotNil(t, conf)

assert.Equal(t, "arg-theme", conf.DefaultLightTheme)
}

func TestParseThemeConfiguration_NoConfig(t *testing.T) {
conf, err := config.Parse([]string{"go run ./cmd"})
require.NoError(t, err)
require.NotNil(t, conf)

assert.Equal(t, "", conf.DefaultLightTheme)
assert.Equal(t, "", conf.DefaultDarkTheme)
assert.Equal(t, "", conf.ForceTheme)
}
3 changes: 3 additions & 0 deletions backend/pkg/headlampconfig/headlampConfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,7 @@ type HeadlampCFG struct {
SessionTTL int
PodDebugImage string
OidcUseCookie bool
DefaultLightTheme string
DefaultDarkTheme string
ForceTheme string
}
12 changes: 12 additions & 0 deletions frontend/src/components/App/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import DetailsDrawer from '../common/Resource/DetailsDrawer';
import Sidebar, { NavigationTabs } from '../Sidebar';
import RouteSwitcher from './RouteSwitcher';
import ShortcutsSettings from './Settings/ShortcutsSettings';
import { applyBackendThemeConfig } from './themeSlice';
import TopBar from './TopBar';
import VersionDialog from './VersionDialog';

Expand Down Expand Up @@ -170,6 +171,17 @@ const fetchConfig = (dispatch: Dispatch<UnknownAction>) => {
}
}

// Apply backend theme configuration if provided
if (config?.defaultLightTheme || config?.defaultDarkTheme || config?.forceTheme) {
dispatch(
applyBackendThemeConfig({
defaultLightTheme: config.defaultLightTheme,
defaultDarkTheme: config.defaultDarkTheme,
forceTheme: config.forceTheme,
})
);
}
Comment thread
illume marked this conversation as resolved.

/**
* Fetches the stateless cluster config from the indexDB and then sends the backend to parse it
* only if the stateless cluster config is enabled in the backend.
Expand Down
31 changes: 26 additions & 5 deletions frontend/src/components/App/Settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export default function Settings() {
const dispatch = useDispatch();
const themeName = useTypedSelector(state => state.theme.name);
const appThemes = useAppThemes();
const forceTheme = useTypedSelector(state => state.config.forceTheme);

useEffect(() => {
dispatch(
Expand Down Expand Up @@ -204,6 +205,19 @@ export default function Settings() {
pb: 5,
}}
>
{forceTheme && (
<Typography
variant="body2"
sx={theme => ({
textAlign: 'center',
color: theme.palette.text.secondary,
fontStyle: 'italic',
mb: 2,
})}
>
{t('translation|Theme has been forced by your administrator')}
</Typography>
)}
<Box
sx={{
display: 'grid',
Expand All @@ -214,18 +228,25 @@ export default function Settings() {
gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))',
gap: 2,
},
opacity: forceTheme ? 0.5 : 1,
pointerEvents: forceTheme ? 'none' : 'auto',
}}
>
{appThemes.map(it => (
<Box
key={it.name}
role="button"
tabIndex={0}
tabIndex={forceTheme ? -1 : 0}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') dispatch(setTheme(it.name));
if (forceTheme) {
return;
}
if (e.key === 'Enter' || e.key === ' ') {
dispatch(setTheme(it.name));
}
}}
sx={{
cursor: 'pointer',
cursor: forceTheme ? 'not-allowed' : 'pointer',
border: themeName === it.name ? '2px solid' : '1px solid',
borderColor: themeName === it.name ? 'primary' : 'divider',
borderRadius: 2,
Expand All @@ -235,10 +256,10 @@ export default function Settings() {
alignItems: 'center',
transition: '0.2 ease',
'&:hover': {
backgroundColor: 'divider',
backgroundColor: forceTheme ? 'transparent' : 'divider',
},
}}
onClick={() => dispatch(setTheme(it.name))}
onClick={() => !forceTheme && dispatch(setTheme(it.name))}
Comment thread
illume marked this conversation as resolved.
>
<ThemePreview theme={it} size={110} />
<Box sx={{ mt: 1 }}>{capitalize(it.name)}</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@
class="MuiBox-root css-exrnj3"
>
<div
class="MuiBox-root css-1a2ne5x"
class="MuiBox-root css-1smd7c5"
>
<div
class="MuiBox-root css-1yhz2ch"
Expand Down
51 changes: 50 additions & 1 deletion frontend/src/components/App/themeSlice.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,14 @@
*/

import React from 'react';
import { vi } from 'vitest';
import { AppLogoProps, AppLogoType } from './AppLogo';
import themeReducer, { initialState, setBrandingAppLogoComponent, setTheme } from './themeSlice';
import themeReducer, {
applyBackendThemeConfig,
initialState,
setBrandingAppLogoComponent,
setTheme,
} from './themeSlice';

describe('themeSlice', () => {
it('should handle initial state', () => {
Expand All @@ -37,4 +43,47 @@ describe('themeSlice', () => {
const actual = themeReducer(initialState, setTheme(themeName));
expect(actual.name).toEqual(themeName);
});

describe('applyBackendThemeConfig', () => {
beforeEach(() => {
localStorage.clear();
// The mock's clear() only resets store; setTheme() writes localStorage.headlampThemePreference
// as a direct property that survives clear(). Delete it explicitly so tests start clean.
delete (localStorage as any).headlampThemePreference;
});

it('should apply forced theme and override current theme', () => {
const state = { ...initialState, name: 'light' };
const actual = themeReducer(state, applyBackendThemeConfig({ forceTheme: 'corporate' }));
expect(actual.name).toEqual('corporate');
});

it('should preserve localStorage preference when forced theme is applied', () => {
localStorage.setItem('headlampThemePreference', 'dark');
const state = { ...initialState, name: 'light' };
themeReducer(state, applyBackendThemeConfig({ forceTheme: 'corporate' }));
expect(localStorage.getItem('headlampThemePreference')).toEqual('dark');
});

it('should not update state if theme has not changed', () => {
const state = { ...initialState, name: 'corporate' };
const actual = themeReducer(state, applyBackendThemeConfig({ forceTheme: 'corporate' }));
expect(actual.name).toEqual('corporate');
});

it('should persist to localStorage when theme is not forced', () => {
// setupTests defines matchMedia with writable:true so direct assignment works;
// Object.defineProperty with configurable:true would throw on a non-configurable property.
(window as any).matchMedia = vi.fn((query: string) => ({
matches: query === '(prefers-color-scheme: light)',
}));
const state = { ...initialState, name: 'old-theme' };
const actual = themeReducer(
state,
applyBackendThemeConfig({ defaultLightTheme: 'solarized-light' })
);
expect(actual.name).toEqual('solarized-light');
expect(localStorage.headlampThemePreference).toEqual('solarized-light');
});
});
});
Loading
Loading