diff --git a/docs/development/plugins/functionality/images/themed-xterm/themed-xterm-light.png b/docs/development/plugins/functionality/images/themed-xterm/themed-xterm-light.png new file mode 100644 index 00000000000..9ca8a1bf1a1 Binary files /dev/null and b/docs/development/plugins/functionality/images/themed-xterm/themed-xterm-light.png differ diff --git a/docs/development/plugins/functionality/index.md b/docs/development/plugins/functionality/index.md index c74bcfad0b6..2014a4f1bcf 100644 --- a/docs/development/plugins/functionality/index.md +++ b/docs/development/plugins/functionality/index.md @@ -194,6 +194,16 @@ Settings. ![screenshot of the theme dropdown](./images/settings-theme-dropdown.png) +The terminal/log surfaces (pod logs, exec, node shell) follow the active +theme automatically. To override their colors, set the optional `terminal` +field on `AppTheme` — `background`, `foreground`, `cursor`, and a 16-color +`ansi` palette. Anything you leave out is auto-derived from the surrounding +MUI palette and contrast-clamped to stay readable on the chosen background. +See the [custom-theme example](https://github.com/kubernetes-sigs/headlamp/tree/main/plugins/examples/custom-theme) +for a working `registerAppTheme({ ..., terminal: { ... } })` call. + +![pod log viewer in light theme](./images/themed-xterm/themed-xterm-light.png) + ### UI Panels Register a side panel with diff --git a/e2e-tests/tests/themedXterm.spec.ts b/e2e-tests/tests/themedXterm.spec.ts new file mode 100644 index 00000000000..c7dcca2e942 --- /dev/null +++ b/e2e-tests/tests/themedXterm.spec.ts @@ -0,0 +1,237 @@ +/* + * 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 { AxeBuilder } from '@axe-core/playwright'; +import { expect, Locator, Page, test } from '@playwright/test'; +import { HeadlampPage } from './headlampPage'; + +type ThemeName = 'light' | 'dark'; +type XtermRoute = 'logs' | 'exec' | 'nodeShell'; + +const themes: ThemeName[] = ['light', 'dark']; +const routes: XtermRoute[] = ['logs', 'exec', 'nodeShell']; + +/** + * Parse a CSS color string like `rgb(245, 245, 245)` or `rgba(51, 51, 51, 1)` + * and return its perceptual luminance in the range [0, 1] (Rec. 709 luma). + * + * Used to assert "looks light/dark" without pinning a specific palette value, + * so the test stays valid if the MUI theme tokens are tweaked. + */ +function luminanceOf(cssColor: string): number { + const match = cssColor.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (!match) { + throw new Error(`Could not parse color: ${cssColor}`); + } + const [r, g, b] = [match[1], match[2], match[3]].map(v => Number(v) / 255); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/** + * Seed the theme preference in localStorage before any app code runs, so that + * the Redux theme slice picks it up at init. + */ +async function seedTheme(page: Page, themeName: ThemeName) { + await page.addInitScript(name => { + try { + window.localStorage.setItem('headlampThemePreference', name); + } catch { + // Some test contexts may not have localStorage available; the test + // assertion below will surface the issue as a luminance mismatch. + } + }, themeName); +} + +/** + * Navigate to the first pod's details page. Skips the test gracefully if the + * cluster has no pods permission (matches the RBAC-tolerant pattern used by + * other specs in this directory). + */ +async function openFirstPodDetails(page: Page): Promise<{ podName: string }> { + const headlampPage = new HeadlampPage(page); + await headlampPage.navigateToCluster('test', process.env.HEADLAMP_TEST_TOKEN); + + const content = await page.content(); + if (!content.includes('Pods') || !content.includes('href="/c/test/pods')) { + test.skip(true, 'No pods permission on this cluster.'); + } + + await headlampPage.navigateTopage('/c/test/pods', /Pods/); + + const podsTable = page.getByRole('table'); + await expect(podsTable).toBeVisible(); + + // Skip when the cluster is reachable but has no pods to open — otherwise we + // would fall through to a non-existent `tbody > tr:nth(0)` row and fail. + const podRowCount = await podsTable.locator('tbody > tr').count(); + if (podRowCount === 0) { + test.skip(true, 'No pods on this cluster to open.'); + } + + const podLink = podsTable + .locator('tbody') + .nth(0) + .locator('tr') + .nth(0) + .locator('td') + .nth(1) + .locator('a'); + const podName = (await podLink.textContent()) ?? ''; + await podLink.click(); + + await expect( + page.getByRole('heading', { level: 1, name: new RegExp(`^Pod: ${podName}$`) }) + ).toBeVisible(); + + return { podName }; +} + +/** + * Open the LogViewer for the first pod and return the `.xterm-viewport` + * locator (the element whose background is the theme-derived color). + */ +async function openLogs(page: Page): Promise { + await openFirstPodDetails(page); + + const showLogsButton = page.getByRole('button', { name: /^Show Logs$/ }); + await showLogsButton.click(); + + const terminal = page.locator('#xterm-container'); + await expect(terminal).toBeVisible(); + + const viewport = terminal.locator('.xterm-viewport'); + await expect(viewport).toBeVisible(); + return viewport; +} + +/** + * Open the Terminal (exec) dialog for the first pod and return its + * `.xterm-viewport` locator. Skips when the test cluster doesn't grant `exec` + * (the action button is rendered behind ``). + */ +async function openExecTerminal(page: Page): Promise { + await openFirstPodDetails(page); + + const execButton = page.getByRole('button', { name: 'Terminal / Exec' }); + if (!(await execButton.isVisible().catch(() => false))) { + test.skip(true, 'No exec permission on this cluster.'); + } + await execButton.click(); + + const terminal = page.locator('#xterm-container'); + await expect(terminal).toBeVisible(); + + const viewport = terminal.locator('.xterm-viewport'); + await expect(viewport).toBeVisible(); + return viewport; +} + +async function openXtermRoute(page: Page, route: XtermRoute): Promise { + if (route === 'logs') return openLogs(page); + if (route === 'exec') return openExecTerminal(page); + return openNodeShell(page); +} + +/** + * Open a node-shell terminal on the first node and return its + * `.xterm-viewport` locator. The action button is gated by AuthVisible + * (`create pod` + `get exec` on the node-shell namespace) and by the node's + * OS being Linux, so we skip gracefully when the cluster doesn't satisfy + * those preconditions. + */ +async function openNodeShell(page: Page): Promise { + const headlampPage = new HeadlampPage(page); + await headlampPage.navigateToCluster('test', process.env.HEADLAMP_TEST_TOKEN); + + const content = await page.content(); + if (!content.includes('href="/c/test/nodes')) { + test.skip(true, 'No nodes permission on this cluster.'); + } + + await headlampPage.navigateTopage('/c/test/nodes', /Nodes/); + + const nodesTable = page.getByRole('table'); + await expect(nodesTable).toBeVisible(); + + const nodeRowCount = await nodesTable.locator('tbody > tr').count(); + if (nodeRowCount === 0) { + test.skip(true, 'No nodes on this cluster to open.'); + } + + const nodeLink = nodesTable + .locator('tbody') + .nth(0) + .locator('tr') + .nth(0) + .locator('td') + .nth(1) + .locator('a'); + await nodeLink.click(); + + const debugButton = page.getByRole('button', { name: 'Debug Node' }); + if (!(await debugButton.isVisible().catch(() => false)) || (await debugButton.isDisabled())) { + test.skip(true, 'Node shell unavailable (non-Linux node or missing RBAC).'); + } + await debugButton.click(); + + const terminal = page.locator('#xterm-container'); + await expect(terminal).toBeVisible(); + + const viewport = terminal.locator('.xterm-viewport'); + await expect(viewport).toBeVisible(); + return viewport; +} + +test.describe('xterm routes are theme-aware and a11y-clean', () => { + for (const route of routes) { + for (const theme of themes) { + test(`${route} viewer in ${theme} theme: background matches theme + axe clean`, async ({ + page, + }) => { + await seedTheme(page, theme); + + const viewport = await openXtermRoute(page, route); + + const bg = await viewport.evaluate( + el => getComputedStyle(el as HTMLElement).backgroundColor + ); + const luminance = luminanceOf(bg); + if (theme === 'light') { + expect(luminance, `expected light background, got ${bg}`).toBeGreaterThan(0.7); + } else { + expect(luminance, `expected dark background, got ${bg}`).toBeLessThan(0.3); + } + + // a11y: scan the whole open xterm activity (toolbar, container/shell + // selectors, reconnect button, search popover for logs) — not just + // the inner `#xterm-container` element, which would miss regressions + // in the surrounding chrome that this PR also re-themes. xterm.js + // renders its own canvas/decoration nodes, so we exclude those + // (color-contrast on a `` is not meaningful) along with the + // surrounding sidebar/topbar that other specs already cover. + const results = await new AxeBuilder({ page }) + .exclude('.xterm-screen') + .exclude('.xterm-text-layer') + .exclude('.xterm-helpers') + .exclude('nav') + .exclude('header') + .analyze(); + expect(results.violations).toStrictEqual([]); + }); + } + } +}); diff --git a/frontend/src/components/common/LogViewer.tsx b/frontend/src/components/common/LogViewer.tsx index 93757fe94b3..8907d67a0d2 100644 --- a/frontend/src/components/common/LogViewer.tsx +++ b/frontend/src/components/common/LogViewer.tsx @@ -20,6 +20,7 @@ import DialogContent from '@mui/material/DialogContent'; import Grid from '@mui/material/Grid'; import InputBase from '@mui/material/InputBase'; import Paper from '@mui/material/Paper'; +import { alpha, useTheme } from '@mui/material/styles'; import { FitAddon } from '@xterm/addon-fit'; import { ISearchOptions, SearchAddon } from '@xterm/addon-search'; import { Terminal as XTerminal } from '@xterm/xterm'; @@ -29,6 +30,7 @@ import { useTranslation } from 'react-i18next'; import { useShortcut } from '../../lib/useShortcut'; import ActionButton from './ActionButton'; import { Dialog, DialogProps } from './Dialog'; +import { getXtermTheme } from './xtermTheme'; export interface LogViewerProps extends DialogProps { logs: string[]; @@ -64,6 +66,8 @@ export function LogViewer(props: LogViewerProps) { ...other } = props; const { t } = useTranslation(); + const muiTheme = useTheme(); + const xtermTheme = React.useMemo(() => getXtermTheme(muiTheme), [muiTheme]); const xtermRef = React.useRef(null); const fitAddonRef = React.useRef(null); const searchAddonRef = React.useRef(null); @@ -101,6 +105,7 @@ export function LogViewer(props: LogViewerProps) { rows: 30, // initial rows before fit lineHeight: 1.21, allowProposedApi: true, + theme: xtermTheme, }); if (!!outXtermRef) { @@ -132,6 +137,12 @@ export function LogViewer(props: LogViewerProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [terminalContainerRef, xtermRef.current]); + React.useEffect(() => { + if (xtermRef.current) { + xtermRef.current.options.theme = xtermTheme; + } + }, [xtermTheme]); + React.useEffect(() => { if (!xtermRef.current) { return; @@ -287,6 +298,8 @@ interface SearchPopoverProps { export function SearchPopover(props: SearchPopoverProps) { const { searchAddonRef, open, onClose } = props; + const muiTheme = useTheme(); + const isDark = muiTheme.palette.mode === 'dark'; const [searchResult, setSearchResult] = React.useState< { resultIndex: number; resultCount: number } | undefined >(undefined); @@ -308,10 +321,10 @@ export function SearchPopover(props: SearchPopoverProps) { const randomId = _.uniqueId('search-input-'); const searchAddonTextDecorationOptions: ISearchOptions['decorations'] = { - matchBackground: '#6d402a', - activeMatchBackground: '#515c6a', - matchOverviewRuler: '#f00', - activeMatchColorOverviewRuler: '#515c6a', + matchBackground: alpha(muiTheme.palette.warning.main, 0.5), + activeMatchBackground: alpha(muiTheme.palette.primary.main, 0.6), + matchOverviewRuler: muiTheme.palette.warning.main, + activeMatchColorOverviewRuler: muiTheme.palette.primary.main, }; useEffect(() => { @@ -342,8 +355,22 @@ export function SearchPopover(props: SearchPopoverProps) { // eslint-disable-next-line react-hooks/exhaustive-deps searchAddonRef.current?.findNext(''); }; + // searchAddonTextDecorationOptions is rebuilt every render, but we still + // want the existing match highlights / overview-ruler markers to update + // when the user toggles themes mid-search, so we depend on the resolved + // decoration colors rather than the object identity. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchText, caseSensitiveChecked, wholeWordMatchChecked, regexChecked, open]); + }, [ + searchText, + caseSensitiveChecked, + wholeWordMatchChecked, + regexChecked, + open, + searchAddonTextDecorationOptions.matchBackground, + searchAddonTextDecorationOptions.activeMatchBackground, + searchAddonTextDecorationOptions.matchOverviewRuler, + searchAddonTextDecorationOptions.activeMatchColorOverviewRuler, + ]); const handleFindNext = () => { searchAddonRef.current?.findNext(searchText, { @@ -381,12 +408,12 @@ export function SearchPopover(props: SearchPopoverProps) { } }; - const baseGray = '#cccccc'; + const searchTextColor = muiTheme.palette.text.primary; const grayText = { - color: baseGray, + color: searchTextColor, }; const redText = { - color: '#f48771', + color: muiTheme.palette.error.main, }; const searchResults = () => { @@ -423,10 +450,15 @@ export function SearchPopover(props: SearchPopoverProps) { ) : ( { - //@todo: This style should match the theme being used. + const popoverBg = theme.palette.background.paper; + const inputBg = theme.palette.action.hover; + const borderColor = theme.palette.divider; + const focusBorder = theme.palette.primary.main; + const checkedBg = alpha(theme.palette.primary.main, isDark ? 0.4 : 0.2); + const disabledColor = theme.palette.action.disabled; return { position: 'absolute', - background: '#252526', + background: popoverBg, top: 8, right: 15, padding: '4px 8px', @@ -435,20 +467,20 @@ export function SearchPopover(props: SearchPopoverProps) { display: 'flex', flexDirection: 'row', alignItems: 'center', - borderLeft: `2px solid #555`, + borderLeft: `2px solid ${borderColor}`, '& .SearchTextArea': { - background: '#3c3c3c', + background: inputBg, display: 'flex', flexDirection: 'row', alignItems: 'center', padding: '1px 4px 2px 0', width: 240, '& .MuiInputBase-root': { - color: baseGray, + color: searchTextColor, fontSize: '0.85rem', border: '1px solid rgba(0,0,0,0)', '&.Mui-focused': { - border: `1px solid #007fd4`, + border: `1px solid ${focusBorder}`, }, '&>input': { padding: '2px 4px', @@ -458,10 +490,10 @@ export function SearchPopover(props: SearchPopoverProps) { margin: '0 1px', padding: theme.spacing(0.5), fontSize: '1.05rem', - color: baseGray, + color: searchTextColor, borderRadius: 4, '&.checked': { - background: '#245779', + background: checkedBg, }, }, }, @@ -474,9 +506,9 @@ export function SearchPopover(props: SearchPopoverProps) { '& .MuiIconButton-root': { padding: 2, fontSize: '1.05rem', - color: baseGray, + color: searchTextColor, '&.Mui-disabled': { - color: '#767677', + color: disabledColor, }, }, }, diff --git a/frontend/src/components/common/Terminal.tsx b/frontend/src/components/common/Terminal.tsx index 6d1b424126b..5eb1bf6feb0 100644 --- a/frontend/src/components/common/Terminal.tsx +++ b/frontend/src/components/common/Terminal.tsx @@ -22,6 +22,7 @@ import FormControl from '@mui/material/FormControl'; 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 { FitAddon } from '@xterm/addon-fit'; import { Terminal as XTerminal } from '@xterm/xterm'; import _ from 'lodash'; @@ -30,6 +31,7 @@ import { useTranslation } from 'react-i18next'; import { getDefaultContainer, resolveContainerName } from '../../helpers/podContainer'; import Pod from '../../lib/k8s/pod'; import { Dialog } from './Dialog'; +import { getXtermTheme } from './xtermTheme'; const decoder = new TextDecoder('utf-8'); const encoder = new TextEncoder(); @@ -73,6 +75,8 @@ export default function Terminal(props: TerminalProps) { currentIdx: 0, }); const { t } = useTranslation(['translation', 'glossary']); + const muiTheme = useTheme(); + const xtermTheme = React.useMemo(() => getXtermTheme(muiTheme), [muiTheme]); // @todo: Give the real exec type when we have it. function setupTerminal(containerRef: HTMLElement, xterm: XTerminal, fitAddon: FitAddon) { @@ -259,6 +263,12 @@ export default function Terminal(props: TerminalProps) { } } + React.useEffect(() => { + if (xtermRef.current) { + xtermRef.current.xterm.options.theme = xtermTheme; + } + }, [xtermTheme]); + React.useEffect( () => { // We need a valid container ref for the terminal to add itself to it. @@ -291,6 +301,7 @@ export default function Terminal(props: TerminalProps) { rows: 30, // initial rows before fit windowsMode: isWindows, allowProposedApi: true, + theme: xtermTheme, }), connected: false, reconnectOnEnter: false, diff --git a/frontend/src/components/common/__snapshots__/Terminal.TerminalAttachEmptyFirstOutput.stories.storyshot b/frontend/src/components/common/__snapshots__/Terminal.TerminalAttachEmptyFirstOutput.stories.storyshot index 46f744c6013..df4836ac572 100644 --- a/frontend/src/components/common/__snapshots__/Terminal.TerminalAttachEmptyFirstOutput.stories.storyshot +++ b/frontend/src/components/common/__snapshots__/Terminal.TerminalAttachEmptyFirstOutput.stories.storyshot @@ -142,7 +142,7 @@ >