Skip to content
Open
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
11 changes: 10 additions & 1 deletion backend/cmd/headlamp.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ const (
type clientConfig struct {
Clusters []Cluster `json:"clusters"`
IsDynamicClusterEnabled bool `json:"isDynamicClusterEnabled"`
DefaultLightTheme string `json:"defaultLightTheme,omitempty"`
DefaultDarkTheme string `json:"defaultDarkTheme,omitempty"`
ForceTheme string `json:"forceTheme,omitempty"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this be a boolean? so that default light/dark theme can be forced

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know, it could.
It felt more natural for me to set --force-theme corporate-light instead of --default-light-theme corporate-light --force-theme
But that's a matter of taste. I can change that if you want.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not a matter of taste. for accessibility reasons there should be an option to force dark or light theme depending on the user preference

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. What behaviour do you want?

  • What should happen to users with dark theme preferences when ran with --default-light-theme corporate-light --force-theme ?
  • Wouldn't it be easier to have two options --force-light-theme and --force-dark-theme as strings?

}

type OauthConfig struct {
Expand Down Expand Up @@ -1769,7 +1772,13 @@ func parseClusterFromKubeConfig(kubeConfigs []string) ([]Cluster, []error) {
func (c *HeadlampConfig) getConfig(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

clientConfig := clientConfig{c.getClusters(), c.EnableDynamicClusters}
clientConfig := clientConfig{
Clusters: c.getClusters(),
IsDynamicClusterEnabled: c.EnableDynamicClusters,
DefaultLightTheme: c.DefaultLightTheme,
DefaultDarkTheme: c.DefaultDarkTheme,
ForceTheme: c.ForceTheme,
}

if err := json.NewEncoder(w).Encode(&clientConfig); err != nil {
logger.Log(logger.LevelError, nil, err, "encoding config")
Expand Down
3 changes: 3 additions & 0 deletions backend/cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ func buildHeadlampCFG(conf *config.Config, kubeConfigStore kubeconfig.ContextSto
ProxyURLs: strings.Split(conf.ProxyURLs, ","),
TLSCertPath: conf.TLSCertPath,
TLSKeyPath: conf.TLSKeyPath,
DefaultLightTheme: conf.DefaultLightTheme,
DefaultDarkTheme: conf.DefaultDarkTheme,
ForceTheme: conf.ForceTheme,
}
}

Expand Down
8 changes: 7 additions & 1 deletion backend/cmd/stateless.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,13 @@ func (c *HeadlampConfig) parseKubeConfig(w http.ResponseWriter, r *http.Request)
return
}

clientConfig := clientConfig{contexts, c.EnableDynamicClusters}
clientConfig := clientConfig{
Clusters: contexts,
IsDynamicClusterEnabled: c.EnableDynamicClusters,
DefaultLightTheme: c.DefaultLightTheme,
DefaultDarkTheme: c.DefaultDarkTheme,
ForceTheme: c.ForceTheme,
}

if err := json.NewEncoder(w).Encode(&clientConfig); err != nil {
logger.Log(logger.LevelError, nil, err, "encoding config")
Expand Down
14 changes: 14 additions & 0 deletions backend/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ 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"`
}

func (c *Config) Validate() error {
Expand All @@ -91,6 +95,13 @@ func (c *Config) Validate() error {
oidc-validator-idp-issuer-url, flags are only meant to be used in inCluster mode`)
}

// Theme configuration warning.
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")
}

// 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 @@ -432,6 +443,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
118 changes: 118 additions & 0 deletions backend/pkg/config/config_theme_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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) {
os.Setenv("HEADLAMP_CONFIG_DEFAULT_LIGHT_THEME", "env-light")
os.Setenv("HEADLAMP_CONFIG_DEFAULT_DARK_THEME", "env-dark")

defer func() {
os.Unsetenv("HEADLAMP_CONFIG_DEFAULT_LIGHT_THEME")
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) {
os.Setenv("HEADLAMP_CONFIG_FORCE_THEME", "env-forced")
defer 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) {
os.Setenv("HEADLAMP_CONFIG_DEFAULT_LIGHT_THEME", "env-theme")
defer 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 @@ -29,4 +29,7 @@ type HeadlampCFG struct {
ProxyURLs []string
TLSCertPath string
TLSKeyPath string
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,
})
);
}

/**
* 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 @@ -201,6 +202,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 @@ -211,18 +225,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 @@ -232,10 +253,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))}
>
<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
42 changes: 41 additions & 1 deletion frontend/src/components/App/themeSlice.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@

import React from 'react';
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 +42,39 @@ describe('themeSlice', () => {
const actual = themeReducer(initialState, setTheme(themeName));
expect(actual.name).toEqual(themeName);
});

describe('applyBackendThemeConfig', () => {
beforeEach(() => {
localStorage.clear();
});

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 clear 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')).toBeNull();
});

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', () => {
const state = { ...initialState, name: 'old-theme' };
const config = { defaultLightTheme: 'solarized-light' };
const actual = themeReducer(state, applyBackendThemeConfig(config));
// Theme changed, so it should be persisted via setTheme (property assignment)
if (actual.name !== state.name) {
expect(localStorage.headlampThemePreference).toEqual(actual.name);
}
});
});
});
Loading
Loading