diff --git a/console/src/components/CommandBlock/ReadOnlyCommandBlock.tsx b/console/src/components/CommandBlock/ReadOnlyCommandBlock.tsx index 4798a4eb1e380..7c5471376e0c3 100644 --- a/console/src/components/CommandBlock/ReadOnlyCommandBlock.tsx +++ b/console/src/components/CommandBlock/ReadOnlyCommandBlock.tsx @@ -71,7 +71,9 @@ export const ReadOnlyCommandBlock = ({ EditorView.theme({ ".highlight": { "background-color": - colorMode === "light" ? colors.purple[200] : colors.purple[700], + colorMode === "light" + ? colors.accentHue[200] + : colors.accentHue[700], }, }), ]; diff --git a/console/src/components/SimpleSelect.tsx b/console/src/components/SimpleSelect.tsx index c861fc22989bc..18d1032d47845 100644 --- a/console/src/components/SimpleSelect.tsx +++ b/console/src/components/SimpleSelect.tsx @@ -37,8 +37,7 @@ const SimpleSelect = forwardRef((props, ref) => { }, _focus: { borderColor: colors.accent.brightPurple, - boxShadow: - "0px 0px 0px 0px hsla(0, 0%, 0%, 0), 0px 0px 0px 0px hsla(0, 0%, 0%, 0), 0px 0px 0px 2px hsla(257, 100%, 65%, 0.24)", // accent.brightPurple, + boxShadow: `0px 0px 0px 0px hsla(0, 0%, 0%, 0), 0px 0px 0px 0px hsla(0, 0%, 0%, 0), ${shadows.input.focus}`, }, _invalid: { borderColor: colors.accent.red, diff --git a/console/src/components/licenseComponents.tsx b/console/src/components/licenseComponents.tsx index 894753144927a..5d918e4eae8c0 100644 --- a/console/src/components/licenseComponents.tsx +++ b/console/src/components/licenseComponents.tsx @@ -312,7 +312,7 @@ export const LicenseKeyCTAContent = ({ textAlign="left" > - + {" "} {selfManagedMode === "enterprise" @@ -353,7 +353,7 @@ export const LicenseKeyCTAContent = ({ Want to learn more?{" "} { + it("names the instance when it has a display name", () => { + expect(documentTitle({ displayName: "prod" })).toEqual( + "Materialize Console · prod", + ); + }); + + it("falls back to the plain title", () => { + expect(documentTitle(undefined)).toEqual("Materialize Console"); + expect(documentTitle({})).toEqual("Materialize Console"); + }); +}); + +describe("parseConsoleAppearance", () => { + it("returns undefined when unconfigured", () => { + expect(parseConsoleAppearance(undefined)).toBeUndefined(); + expect(parseConsoleAppearance(null)).toBeUndefined(); + }); + + it("keeps a display name", () => { + expect(parseConsoleAppearance({ displayName: "prod" })).toEqual({ + displayName: "prod", + }); + }); + + it("treats an empty display name as unset", () => { + expect(parseConsoleAppearance({ displayName: "" })).toEqual({ + displayName: undefined, + }); + }); +}); diff --git a/console/src/config/appearance.ts b/console/src/config/appearance.ts new file mode 100644 index 0000000000000..23679922e320d --- /dev/null +++ b/console/src/config/appearance.ts @@ -0,0 +1,62 @@ +// Copyright Materialize, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +/** + * @module + * Per-instance appearance, set on the Materialize resource and delivered to + * the browser in app-config.json. Self-managed only, since Cloud serves one + * console for all of a user's regions. + */ + +/** + * The hues an instance may accent its console with. Each names a hue of the + * palette, which the theme reads the appropriate shades of. See + * `~/theme/accent`. + */ +export const ACCENT_COLORS = ["blue", "orange", "purple"] as const; + +export type AccentColor = (typeof ACCENT_COLORS)[number]; + +export interface ConsoleAppearance { + /** A short name for the instance, such as "dev" or "prod". */ + displayName?: string; + accentColor?: AccentColor; +} + +const BASE_DOCUMENT_TITLE = "Materialize Console"; + +/** Builds the browser tab title, which names the instance when configured. */ +export const documentTitle = (appearance: ConsoleAppearance | undefined) => + appearance?.displayName + ? `${BASE_DOCUMENT_TITLE} · ${appearance.displayName}` + : BASE_DOCUMENT_TITLE; + +const isAccentColor = (value: unknown): value is AccentColor => + ACCENT_COLORS.includes(value as AccentColor); + +/** + * Validates the appearance read out of app-config.json. + * + * Orchestratord writes that file and may be newer than the console it serves, + * so an accent color this build doesn't know is dropped rather than handed to + * the theme as an unresolvable color. + */ +export const parseConsoleAppearance = ( + appearance: { displayName?: string; accentColor?: string } | null | undefined, +): ConsoleAppearance | undefined => { + if (!appearance) { + return undefined; + } + return { + displayName: appearance.displayName || undefined, + accentColor: isAccentColor(appearance.accentColor) + ? appearance.accentColor + : undefined, + }; +}; diff --git a/console/src/config/importAppConfig.ts b/console/src/config/importAppConfig.ts index 54027f15f36ea..b2672b71164a8 100644 --- a/console/src/config/importAppConfig.ts +++ b/console/src/config/importAppConfig.ts @@ -8,6 +8,7 @@ // by the Apache License, Version 2.0. import { type SelfManagedAuthMode } from "./AppConfig"; +import { type ConsoleAppearance, parseConsoleAppearance } from "./appearance"; const DEFAULT_APP_CONFIG = { auth: { @@ -35,6 +36,7 @@ export function importAppConfig(): { mode: SelfManagedAuthMode; }; balancerdDnsNames?: string[]; + appearance?: ConsoleAppearance; } { if (process.env.NODE_ENV === "test") { return DEFAULT_APP_CONFIG; @@ -45,9 +47,11 @@ export function importAppConfig(): { mode: SelfManagedAuthMode; }; balancerd_dns_names?: string[]; + appearance?: { displayName?: string; accentColor?: string }; }; return { auth: json.auth, balancerdDnsNames: json.balancerd_dns_names, + appearance: parseConsoleAppearance(json.appearance), }; } diff --git a/console/src/index.tsx b/console/src/index.tsx index 5bc7fd0d6e91c..73810c9fd4ffa 100644 --- a/console/src/index.tsx +++ b/console/src/index.tsx @@ -22,10 +22,14 @@ import "~/sentry"; import React from "react"; import { createRoot } from "react-dom/client"; +import { appConfig } from "~/config/AppConfig"; +import { documentTitle } from "~/config/appearance"; import { App } from "~/platform/App"; import { addChunkLoadErrorListener } from "./utils/chunkLoadErrorHandler"; +document.title = documentTitle(appConfig.appearance); + const rootEl = document.createElement("div"); document.body.appendChild(rootEl); const root = createRoot(rootEl); diff --git a/console/src/layouts/NavBar/NavItem.tsx b/console/src/layouts/NavBar/NavItem.tsx index 6c3259e79a0aa..8966e0498c96b 100644 --- a/console/src/layouts/NavBar/NavItem.tsx +++ b/console/src/layouts/NavBar/NavItem.tsx @@ -67,8 +67,7 @@ export const NavItem = (props: NavItemProps) => { showActiveStyle ? { ...NAV_HOVER_STYLES, - // slightly more opaque than colors.background.accent - bg: "rgba(90, 52, 203, 0.2)", + bg: colors.background.accentActive, } : undefined } diff --git a/console/src/platform/shell/HistorySearchModal.tsx b/console/src/platform/shell/HistorySearchModal.tsx index 8a5723f064c66..ce4239687ed22 100644 --- a/console/src/platform/shell/HistorySearchModal.tsx +++ b/console/src/platform/shell/HistorySearchModal.tsx @@ -63,7 +63,7 @@ const HistoryList = ({ const { colors } = useTheme(); const { colorMode } = useColorMode(); const highlightBgColor = - colorMode === "light" ? colors.purple[200] : colors.purple[700]; + colorMode === "light" ? colors.accentHue[200] : colors.accentHue[700]; const highlightFgColor = colorMode === "light" ? "unset" : colors.foreground.primary; diff --git a/console/src/theme/accent.test.ts b/console/src/theme/accent.test.ts new file mode 100644 index 0000000000000..fff421e0d58ca --- /dev/null +++ b/console/src/theme/accent.test.ts @@ -0,0 +1,109 @@ +// Copyright Materialize, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +import { ACCENT_COLORS } from "~/config/appearance"; + +import { accentHueFor, buildAccentPalettes } from "./accent"; +import colors from "./colors"; +import { darkColors } from "./dark"; +import { lightColors } from "./light"; + +describe("accentHueFor", () => { + it("defaults to Materialize purple", () => { + expect(accentHueFor(undefined)).toBe(colors.purple); + }); + + it("resolves a configured accent color to its hue", () => { + expect(accentHueFor("blue")).toBe(colors.blue); + }); +}); + +describe("buildAccentPalettes", () => { + // An instance that configures nothing must render exactly as it did before + // the accent became configurable, so these pin the shades the themes used + // when they were hardcoded. + it("reproduces the original purple tokens by default", () => { + const { light, dark } = buildAccentPalettes(undefined); + + expect(light).toEqual({ + primary: "#472F85", + bright: "#5A34CB", + wash: "rgba(90, 52, 203, 0.08)", + focusRing: "0px 0px 0px 2px rgba(127, 78, 255, 0.24)", + }); + expect(dark).toEqual({ + primary: "#7F4EFF", + bright: "#B59AFF", + wash: "rgba(181, 154, 255, 0.08)", + focusRing: "0px 0px 0px 2px rgba(181, 154, 255, 0.4)", + }); + }); + + it("washes the active navigation item the same in both themes", () => { + expect(buildAccentPalettes(undefined).activeWash).toEqual( + "rgba(90, 52, 203, 0.2)", + ); + }); + + it("accents a configured hue with that hue's shades", () => { + const { light, dark } = buildAccentPalettes("orange"); + + expect(light.primary).toEqual(colors.orange[600]); + expect(light.bright).toEqual(colors.orange[600]); + expect(dark.primary).toEqual(colors.orange[500]); + expect(dark.bright).toEqual(colors.orange[300]); + }); + + it("washes the accent at the same opacity for any hue", () => { + const { light, dark } = buildAccentPalettes("blue"); + + expect(light.wash).toEqual("rgba(0, 114, 180, 0.08)"); + expect(dark.wash).toEqual("rgba(89, 195, 255, 0.08)"); + }); +}); + +/** WCAG relative luminance of a `#rgb` or `#rrggbb` color. */ +const luminance = (hex: string) => { + const digits = hex.replace("#", ""); + const width = digits.length / 3; + const channels = [0, 1, 2] + .map((i) => digits.slice(i * width, (i + 1) * width)) + .map((channel) => (width === 1 ? channel.repeat(2) : channel)) + .map((channel) => parseInt(channel, 16) / 255) + .map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; +}; + +const contrast = (a: string, b: string) => { + const [dimmer, brighter] = [luminance(a), luminance(b)].sort((x, y) => x - y); + return (brighter + 0.05) / (dimmer + 0.05); +}; + +describe("accent shades", () => { + // The shades are chosen by hand per hue, so hold them to the ratios that + // choice promises rather than trusting the table. + it.each(ACCENT_COLORS)("stay legible for %s", (accentColor) => { + const { light, dark } = buildAccentPalettes(accentColor); + + // Drawn as text on the page, and as a surface behind a white label. + expect( + contrast(light.bright, lightColors.background.primary), + ).toBeGreaterThanOrEqual(4.5); + expect( + contrast(light.primary, lightColors.foreground.primaryButtonLabel), + ).toBeGreaterThanOrEqual(4.5); + expect( + contrast(dark.bright, darkColors.background.secondary), + ).toBeGreaterThanOrEqual(4.5); + // Large, bold button labels, which WCAG holds to 3:1. + expect( + contrast(dark.primary, darkColors.foreground.primaryButtonLabel), + ).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/console/src/theme/accent.ts b/console/src/theme/accent.ts new file mode 100644 index 0000000000000..47221c79aba0e --- /dev/null +++ b/console/src/theme/accent.ts @@ -0,0 +1,147 @@ +// Copyright Materialize, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +/** + * @module + * The hue the console accents itself with: the active navigation item, primary + * buttons, links, focus rings, selection highlights, and the like. + * + * Self-managed instances can point this at another hue so that their consoles + * are distinguishable. Colors that carry meaning are left alone, so an error + * stays red and a healthy object stays green whatever the accent. + */ + +import { appConfig } from "~/config/AppConfig"; +import { type AccentColor } from "~/config/appearance"; + +import colors from "./colors"; + +/** Materialize purple, used when an instance configures nothing. */ +export const DEFAULT_ACCENT_COLOR: AccentColor = "purple"; + +/** + * One hue of the palette, from its palest shade to its deepest. + * + * Every hue carries at least these shades, so a component can read the same + * position whichever hue is configured. + */ +export type AccentHue = typeof colors.purple; + +type Shade = keyof AccentHue; + +/** + * The shades each hue accents with. + * + * The palette's hues do not run parallel: a shade that is vivid in one hue is + * muted or washed out in another, so each hue names its own. `primary` is a + * surface behind white label text and `bright` is drawn as text on the page, + * so both are kept at 4.5:1 or better against what they sit against. Purple + * is the default, and names the shades the rest of the palette was designed + * around. + * + * TODO(#38691): a palette drawn as an even scale, rather than shades picked + * out of hues that were never meant to be interchangeable, would let this be + * a single rule instead of a table. That needs design input. + */ +const ACCENT_SHADES: Record< + AccentColor, + { light: { primary: Shade; bright: Shade }; dark: { primary: Shade } } +> = { + purple: { light: { primary: 600, bright: 500 }, dark: { primary: 400 } }, + orange: { light: { primary: 600, bright: 600 }, dark: { primary: 500 } }, + blue: { light: { primary: 600, bright: 600 }, dark: { primary: 600 } }, +}; + +/** + * The shade the dark theme draws accented text in. + * + * The dark themes accent against one background rather than the palette's + * whole range, so a single pale shade reads for every hue. + */ +const DARK_BRIGHT_SHADE: Shade = 300; + +/** The shade the light theme rings a focused input with. */ +const LIGHT_FOCUS_SHADE: Shade = 400; + +// Opacity of the wash behind hovered navigation items. +const WASH_ALPHA = 0.08; +// Opacity of the wash behind the active navigation item. +const ACTIVE_WASH_ALPHA = 0.2; +// Opacity of the ring around a focused input. +const LIGHT_FOCUS_RING_ALPHA = 0.24; +const DARK_FOCUS_RING_ALPHA = 0.4; + +/** `hex` at `alpha` opacity. */ +const withAlpha = (hex: string, alpha: number) => { + const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +}; + +/** The hue an accent color names. */ +export const accentHueFor = (accentColor: AccentColor | undefined): AccentHue => + colors[accentColor ?? DEFAULT_ACCENT_COLOR]; + +export interface AccentPalette { + /** Accented surfaces, such as a primary button's background. */ + primary: string; + /** Accented detail against a page background: links, borders, icons. */ + bright: string; + /** A wash of the accent, for accented backgrounds behind ordinary text. */ + wash: string; + /** The ring drawn around a focused input. */ + focusRing: string; +} + +export interface AccentPalettes { + light: AccentPalette; + dark: AccentPalette; + /** + * The wash behind the active navigation item, stronger than `wash` and + * shared by both themes. + */ + activeWash: string; +} + +/** Both themes' accent tokens for an accent color. */ +export const buildAccentPalettes = ( + accentColor: AccentColor | undefined, +): AccentPalettes => { + const hue = accentHueFor(accentColor); + const shades = ACCENT_SHADES[accentColor ?? DEFAULT_ACCENT_COLOR]; + const darkBright = hue[DARK_BRIGHT_SHADE]; + const lightBright = hue[shades.light.bright]; + + return { + light: { + primary: hue[shades.light.primary], + bright: lightBright, + wash: withAlpha(lightBright, WASH_ALPHA), + focusRing: `0px 0px 0px 2px ${withAlpha( + hue[LIGHT_FOCUS_SHADE], + LIGHT_FOCUS_RING_ALPHA, + )}`, + }, + dark: { + primary: hue[shades.dark.primary], + bright: darkBright, + wash: withAlpha(darkBright, WASH_ALPHA), + focusRing: `0px 0px 0px 2px ${withAlpha( + darkBright, + DARK_FOCUS_RING_ALPHA, + )}`, + }, + activeWash: withAlpha(lightBright, ACTIVE_WASH_ALPHA), + }; +}; + +export const accentHue = accentHueFor(appConfig.appearance?.accentColor); + +export const accentPalettes = buildAccentPalettes( + appConfig.appearance?.accentColor, +); diff --git a/console/src/theme/dark.ts b/console/src/theme/dark.ts index 4221173e48ca3..2cdfb03b79494 100644 --- a/console/src/theme/dark.ts +++ b/console/src/theme/dark.ts @@ -14,12 +14,14 @@ */ import { BasePalette, ComponentOverrides, ThemeColors, ThemeShadows } from "."; +import { accentHue, accentPalettes } from "./accent"; import colors from "./colors"; const base: BasePalette = { + accentHue, accent: { - purple: colors.purple[400], - brightPurple: colors.purple[300], + purple: accentPalettes.dark.primary, + brightPurple: accentPalettes.dark.bright, green: colors.green[400], darkGreen: colors.green[450], indigo: colors.indigo[50], @@ -38,7 +40,8 @@ const base: BasePalette = { background: { // robinclowers: I added this based on Parker's design for the new navigation hover // style. There may be a better / more idomatic way to fit it into our color system. - accent: "rgba(181, 154, 255, 0.08)", + accent: accentPalettes.dark.wash, + accentActive: accentPalettes.activeWash, primary: colors.gray[900], secondary: colors.gray[800], tertiary: colors.gray[700], @@ -117,6 +120,6 @@ export const darkShadows: ThemeShadows = { 0px 1px 0px 0px rgba(255, 255, 255, 0.08) inset`, input: { error: "0px 0px 0px 2px hsla(343, 95%, 46%, 0.24)", - focus: "0px 0px 0px 2px rgba(181, 154, 255, 0.40)", + focus: accentPalettes.dark.focusRing, }, }; diff --git a/console/src/theme/index.tsx b/console/src/theme/index.tsx index 76d7aa0583cff..236e2ada20fc6 100644 --- a/console/src/theme/index.tsx +++ b/console/src/theme/index.tsx @@ -35,13 +35,22 @@ import { SELECT_MENU_Z_INDEX } from "~/layouts/zIndex"; import colors, { gradients } from "~/theme/colors"; import * as components from "~/theme/components"; +import { type AccentHue } from "./accent"; import { darkColors, darkShadows } from "./dark"; import { lightColors, lightShadows } from "./light"; import type { TextStyles } from "./typography"; import { typographySystem } from "./typography"; export interface BasePalette { + /** + * The hue the theme accents itself with, exposed whole for the few places + * that need a shade the `accent` tokens don't name. + */ + accentHue: AccentHue; accent: { + // `purple` and `brightPurple` carry the configured accent, which is + // Materialize purple by default but need not be. Every other name here is + // literal. purple: string; brightPurple: string; green: string; @@ -61,6 +70,7 @@ export interface BasePalette { }; background: { accent: string; + accentActive: string; primary: string; secondary: string; tertiary: string; diff --git a/console/src/theme/light.ts b/console/src/theme/light.ts index 1f8a2b570ae3e..4257ccb84190c 100644 --- a/console/src/theme/light.ts +++ b/console/src/theme/light.ts @@ -14,12 +14,14 @@ */ import { BasePalette, ComponentOverrides, ThemeColors, ThemeShadows } from "."; +import { accentHue, accentPalettes } from "./accent"; import colors from "./colors"; const base: BasePalette = { + accentHue, accent: { - purple: colors.purple[600], - brightPurple: colors.purple[500], + purple: accentPalettes.light.primary, + brightPurple: accentPalettes.light.bright, darkGreen: colors.green[450], indigo: colors.indigo[50], green: colors.green[500], @@ -38,7 +40,8 @@ const base: BasePalette = { background: { // robinclowers: I added this based on Parker's design for the new navigation hover // style. There may be a better / more idomatic way to fit it into our color system. - accent: "rgba(90, 52, 203, 0.08)", + accent: accentPalettes.light.wash, + accentActive: accentPalettes.activeWash, primary: colors.white, secondary: colors.gray[50], tertiary: colors.gray[100], @@ -113,6 +116,6 @@ export const lightShadows: ThemeShadows = { `, input: { error: "0px 0px 0px 2px hsla(343, 95%, 46%, 0.24)", - focus: "0px 0px 0px 2px hsla(257, 100%, 65%, 0.24)", + focus: accentPalettes.light.focusRing, }, }; diff --git a/doc/user/data/self_managed/materialize_crd_descriptions_v1.json b/doc/user/data/self_managed/materialize_crd_descriptions_v1.json index 65d928a44eaa1..610716e2bf099 100644 --- a/doc/user/data/self_managed/materialize_crd_descriptions_v1.json +++ b/doc/user/data/self_managed/materialize_crd_descriptions_v1.json @@ -50,6 +50,14 @@ "required": false, "deprecated": false }, + { + "name": "consoleAppearance", + "type": "ConsoleAppearance", + "description": "Appearance overrides for this instance's console.\n\nThis field is excluded from the rollout hash and changes will not trigger a rollout.", + "default": null, + "required": false, + "deprecated": false + }, { "name": "consoleExternalCertificateSpec", "type": "MaterializeCertSpec", @@ -249,8 +257,8 @@ }, { "name": "privateKeyAlgorithm", - "type": "CertificatePrivateKeyAlgorithm", - "description": "Optional algorithm to use for the private key. If not specified, a recommended default will be chosen.", + "type": "Enum", + "description": "Optional algorithm to use for the private key. If not specified, a recommended default will be chosen.\n\nValid values:\n- `RSA`\n- `ECDSA`\n- `Ed25519`", "default": null, "required": false, "deprecated": false @@ -331,6 +339,27 @@ } ] ], + [ + "ConsoleAppearance", + [ + { + "name": "accentColor", + "type": "Enum", + "description": "The hue the console accents its interface with. This recolors accented\nelements such as the active navigation item, primary buttons, links and\nfocus rings. Colors that carry meaning, such as the red of an error or\nthe green of a healthy object, are left alone.\n\nDefaults to Materialize purple.\n\nValid values:\n- `blue`\n- `orange`\n- `purple`", + "default": null, + "required": false, + "deprecated": false + }, + { + "name": "displayName", + "type": "String", + "description": "A short name for this instance, such as `dev` or `prod`. The console\nappends it to its browser tab title.", + "default": null, + "required": false, + "deprecated": false + } + ] + ], [ "io.k8s.api.core.v1.ResourceRequirements", [ diff --git a/doc/user/data/self_managed/materialize_crd_descriptions_v1alpha1.json b/doc/user/data/self_managed/materialize_crd_descriptions_v1alpha1.json index 1a280f1264705..ffe3930af8d5b 100644 --- a/doc/user/data/self_managed/materialize_crd_descriptions_v1alpha1.json +++ b/doc/user/data/self_managed/materialize_crd_descriptions_v1alpha1.json @@ -50,6 +50,14 @@ "required": false, "deprecated": false }, + { + "name": "consoleAppearance", + "type": "ConsoleAppearance", + "description": "Appearance overrides for this instance's console.", + "default": null, + "required": false, + "deprecated": false + }, { "name": "consoleExternalCertificateSpec", "type": "MaterializeCertSpec", @@ -273,8 +281,8 @@ }, { "name": "privateKeyAlgorithm", - "type": "CertificatePrivateKeyAlgorithm", - "description": "Optional algorithm to use for the private key. If not specified, a recommended default will be chosen.", + "type": "Enum", + "description": "Optional algorithm to use for the private key. If not specified, a recommended default will be chosen.\n\nValid values:\n- `RSA`\n- `ECDSA`\n- `Ed25519`", "default": null, "required": false, "deprecated": false @@ -355,6 +363,27 @@ } ] ], + [ + "ConsoleAppearance", + [ + { + "name": "accentColor", + "type": "Enum", + "description": "The hue the console accents its interface with. This recolors accented\nelements such as the active navigation item, primary buttons, links and\nfocus rings. Colors that carry meaning, such as the red of an error or\nthe green of a healthy object, are left alone.\n\nDefaults to Materialize purple.\n\nValid values:\n- `blue`\n- `orange`\n- `purple`", + "default": null, + "required": false, + "deprecated": false + }, + { + "name": "displayName", + "type": "String", + "description": "A short name for this instance, such as `dev` or `prod`. The console\nappends it to its browser tab title.", + "default": null, + "required": false, + "deprecated": false + } + ] + ], [ "io.k8s.api.core.v1.ResourceRequirements", [ diff --git a/src/cloud-resources/src/bin/crd_writer.rs b/src/cloud-resources/src/bin/crd_writer.rs index b2355d5656751..0ef7d32bf8015 100644 --- a/src/cloud-resources/src/bin/crd_writer.rs +++ b/src/cloud-resources/src/bin/crd_writer.rs @@ -113,20 +113,7 @@ impl DocsField { types_map: &mut IndexMap>, processed_types: &mut IndexSet, ) -> String { - // Check for $ref first - let ref_str_opt = props.get("$ref").and_then(|r| r.as_str()).or_else(|| { - // We assume there is only one non-null type in anyOf - props - .get("oneOf") - .or_else(|| props.get("anyOf")) - .and_then(|o| o.as_array()) - .and_then(|arr| { - arr.iter() - .find_map(|v| v.get("$ref").and_then(|r| r.as_str())) - }) - }); - - if let Some(ref_str) = ref_str_opt { + if let Some(ref_str) = field_ref(props) { let type_name = ref_str .split('/') .next_back() @@ -179,6 +166,22 @@ impl DocsField { } } +// The type a field refers to, either directly or as the non-null variant of an +// optional field. +fn field_ref(props: &serde_json::Value) -> Option<&str> { + props.get("$ref").and_then(|r| r.as_str()).or_else(|| { + // We assume there is only one non-null type in anyOf + props + .get("oneOf") + .or_else(|| props.get("anyOf")) + .and_then(|o| o.as_array()) + .and_then(|arr| { + arr.iter() + .find_map(|v| v.get("$ref").and_then(|r| r.as_str())) + }) + }) +} + // Get type string as reported by the JSON schema fn get_json_schema_type(schema_json: &serde_json::Value) -> String { let obj = schema_json @@ -247,7 +250,7 @@ fn extract_required_fields(props: &serde_json::Value) -> IndexSet { // Check if a resolved schema is an enum fn is_enum_type(resolved: &serde_json::Value) -> bool { - resolved.get("oneOf").is_some() + resolved.get("oneOf").is_some() || resolved.get("enum").is_some() } // Get enum description text for a field @@ -256,7 +259,7 @@ fn get_enum_description( root_schema: &serde_json::Value, default: &Option, ) -> Option { - let resolved = resolve_ref(root_schema, field_props.get("$ref")?.as_str()?)?; + let resolved = resolve_ref(root_schema, field_ref(field_props)?)?; if is_enum_type(resolved) { return Some(format_enum_variants_from_json(resolved, default)); } @@ -277,27 +280,42 @@ fn format_enum_variants_from_json( .and_then(|d| d.as_str()) .map(|s| s.to_string()); - // Enums use oneOf array containing variant descriptions - let variants: IndexMap> = schema_json - .get("oneOf") - .expect("schemars uses oneOf with const values for enums") - .as_array() - .expect("oneOf is always an array") - .into_iter() - .map(|variant| { - let name = variant - .get("const") - .expect("we only handle const enums currently") - .as_str() - .expect("enum const values should always be strings") - .to_owned(); - let description = variant - .get("description") - .and_then(|d| d.as_str()) - .map(|d| d.to_owned()); - (name, description) - }) - .collect(); + // schemars describes an enum as a oneOf of const values when its variants + // carry doc comments, and as a plain array of the values when they don't. + let variants: IndexMap> = match schema_json.get("oneOf") { + Some(one_of) => one_of + .as_array() + .expect("oneOf is always an array") + .into_iter() + .map(|variant| { + let name = variant + .get("const") + .expect("we only handle const enums currently") + .as_str() + .expect("enum const values should always be strings") + .to_owned(); + let description = variant + .get("description") + .and_then(|d| d.as_str()) + .map(|d| d.to_owned()); + (name, description) + }) + .collect(), + None => schema_json + .get("enum") + .expect("is_enum_type checked for oneOf or enum") + .as_array() + .expect("enum is always an array") + .into_iter() + .map(|value| { + let name = value + .as_str() + .expect("enum values should always be strings") + .to_owned(); + (name, None) + }) + .collect(), + }; // Format variants for (variant_name, variant_description) in &variants { diff --git a/src/cloud-resources/src/crd.rs b/src/cloud-resources/src/crd.rs index b1a828d3d4d91..81710510cc050 100644 --- a/src/cloud-resources/src/crd.rs +++ b/src/cloud-resources/src/crd.rs @@ -68,6 +68,42 @@ pub struct MaterializeCertSpec { pub private_key_size: Option, } +/// Appearance overrides for an instance's console. +/// +/// Consoles are otherwise identical, so an operator running several +/// Materialize instances cannot tell from a browser tab which instance a +/// console is pointed at. +#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ConsoleAppearance { + /// A short name for this instance, such as `dev` or `prod`. The console + /// appends it to its browser tab title. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// The hue the console accents its interface with. This recolors accented + /// elements such as the active navigation item, primary buttons, links and + /// focus rings. Colors that carry meaning, such as the red of an error or + /// the green of a healthy object, are left alone. + /// + /// Defaults to Materialize purple. + #[serde(skip_serializing_if = "Option::is_none")] + pub accent_color: Option, +} + +/// A hue of the console's palette. The console reads lighter or darker shades +/// of it to suit the viewer's light or dark theme. +/// +/// Hues that the console already spends on meaning are deliberately absent: +/// accenting an instance in red or green would leave an error or an unhealthy +/// object competing with the furniture for attention. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum ConsoleAccentColor { + Blue, + Orange, + Purple, +} + pub trait ManagedResource: Resource + Sized { fn default_labels(&self) -> BTreeMap { BTreeMap::new() diff --git a/src/cloud-resources/src/crd/console.rs b/src/cloud-resources/src/crd/console.rs index 5b8f0e7403e4a..e89d50bd601c1 100644 --- a/src/cloud-resources/src/crd/console.rs +++ b/src/cloud-resources/src/crd/console.rs @@ -16,7 +16,7 @@ use kube::{CustomResource, Resource, ResourceExt}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::crd::{ManagedResource, MaterializeCertSpec, new_resource_id}; +use crate::crd::{ConsoleAppearance, ManagedResource, MaterializeCertSpec, new_resource_id}; use mz_server_core::listeners::AuthenticatorKind; pub mod v1alpha1 { @@ -88,6 +88,10 @@ pub mod v1alpha1 { #[serde(default)] pub authenticator_kind: AuthenticatorKind, + /// Appearance overrides for this console. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub appearance: Option, + // This can be set to override the randomly chosen resource id pub resource_id: Option, } diff --git a/src/cloud-resources/src/crd/materialize.rs b/src/cloud-resources/src/crd/materialize.rs index 714b1c43963c8..7d28a73cafe77 100644 --- a/src/cloud-resources/src/crd/materialize.rs +++ b/src/cloud-resources/src/crd/materialize.rs @@ -32,7 +32,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use uuid::Uuid; -use crate::crd::{ManagedResource, MaterializeCertSpec, new_resource_id}; +use crate::crd::{ConsoleAppearance, ManagedResource, MaterializeCertSpec, new_resource_id}; use mz_server_core::listeners::AuthenticatorKind; pub const LAST_KNOWN_ACTIVE_GENERATION_ANNOTATION: &str = @@ -174,6 +174,9 @@ pub mod v1alpha1 { pub balancerd_replicas: Option, /// Number of console pods to create. pub console_replicas: Option, + /// Appearance overrides for this instance's console. + #[serde(skip_serializing_if = "Option::is_none")] + pub console_appearance: Option, /// Name of the kubernetes service account to use. /// If not set, we will create one with the same name as this Materialize object. @@ -862,6 +865,7 @@ pub mod v1alpha1 { console_resource_requirements: value.spec.console_resource_requirements, balancerd_replicas: value.spec.balancerd_replicas, console_replicas: value.spec.console_replicas, + console_appearance: value.spec.console_appearance, service_account_name: value.spec.service_account_name, service_account_annotations: value.spec.service_account_annotations, service_account_labels: value.spec.service_account_labels, @@ -987,6 +991,12 @@ pub mod v1alpha1 { with = "double_option", skip_serializing_if = "Option::is_none" )] + pub console_appearance: PartialField, + #[serde( + default, + with = "double_option", + skip_serializing_if = "Option::is_none" + )] pub service_account_name: PartialField, #[serde( default, @@ -1182,6 +1192,7 @@ pub mod v1alpha1 { console_resource_requirements, balancerd_replicas, console_replicas, + console_appearance, service_account_name, service_account_annotations, service_account_labels, @@ -1216,6 +1227,7 @@ pub mod v1alpha1 { console_resource_requirements: present_opt(console_resource_requirements), balancerd_replicas: present_opt(balancerd_replicas), console_replicas: present_opt(console_replicas), + console_appearance: present_opt(console_appearance), service_account_name: present_opt(service_account_name), service_account_annotations: present_opt(service_account_annotations), service_account_labels: present_opt(service_account_labels), @@ -1301,6 +1313,7 @@ pub mod v1alpha1 { console_resource_requirements, balancerd_replicas, console_replicas, + console_appearance, service_account_name, service_account_annotations, service_account_labels, @@ -1332,6 +1345,7 @@ pub mod v1alpha1 { console_resource_requirements, balancerd_replicas, console_replicas, + console_appearance, service_account_name, service_account_annotations, service_account_labels, @@ -1467,6 +1481,11 @@ pub mod v1 { /// /// This field is excluded from the rollout hash and changes will not trigger a rollout. pub console_replicas: Option, + /// Appearance overrides for this instance's console. + /// + /// This field is excluded from the rollout hash and changes will not trigger a rollout. + #[serde(skip_serializing_if = "Option::is_none")] + pub console_appearance: Option, /// Name of the kubernetes service account to use. /// If not set, we will create one with the same name as this Materialize object. @@ -1603,6 +1622,7 @@ pub mod v1 { console_resource_requirements: None, balancerd_replicas: None, console_replicas: None, + console_appearance: None, service_account_name: self.spec.service_account_name.clone(), service_account_annotations: self.spec.service_account_annotations.clone(), service_account_labels: self.spec.service_account_labels.clone(), @@ -2079,6 +2099,7 @@ pub mod v1 { console_resource_requirements: value.spec.console_resource_requirements, balancerd_replicas: value.spec.balancerd_replicas, console_replicas: value.spec.console_replicas, + console_appearance: value.spec.console_appearance, service_account_name: value.spec.service_account_name, service_account_annotations, service_account_labels: value.spec.service_account_labels, @@ -2227,6 +2248,12 @@ pub mod v1 { with = "double_option", skip_serializing_if = "Option::is_none" )] + pub console_appearance: PartialField, + #[serde( + default, + with = "double_option", + skip_serializing_if = "Option::is_none" + )] pub service_account_name: PartialField, #[serde( default, @@ -2403,6 +2430,7 @@ pub mod v1 { console_resource_requirements, balancerd_replicas, console_replicas, + console_appearance, service_account_name, service_account_annotations, service_account_labels, @@ -2434,6 +2462,7 @@ pub mod v1 { console_resource_requirements: present_opt(console_resource_requirements), balancerd_replicas: present_opt(balancerd_replicas), console_replicas: present_opt(console_replicas), + console_appearance: present_opt(console_appearance), service_account_name: present_opt(service_account_name), service_account_annotations: present_opt(service_account_annotations), service_account_labels: present_opt(service_account_labels), @@ -2516,6 +2545,7 @@ pub mod v1 { console_resource_requirements, balancerd_replicas, console_replicas, + console_appearance, service_account_name, service_account_annotations, service_account_labels, @@ -2558,6 +2588,7 @@ pub mod v1 { console_resource_requirements, balancerd_replicas, console_replicas, + console_appearance, service_account_name, service_account_annotations, service_account_labels, @@ -2793,6 +2824,7 @@ mod tests { use super::v1alpha1::{Materialize, MaterializeSpec, MaterializeStatus}; use super::{DEFAULT_ROLLOUT_REQUEST_TIMEOUT, FORCE_ROLLOUT_ANNOTATION, RolloutRequestTimeout}; + use crate::crd::{ConsoleAccentColor, ConsoleAppearance}; #[mz_ore::test] #[cfg_attr(miri, ignore)] // can't call foreign function `sha256_compress` on OS `linux` @@ -3432,4 +3464,32 @@ mod tests { } assert!(!value.as_object().unwrap().contains_key("status")); } + + #[mz_ore::test] + #[cfg_attr(miri, ignore)] // can't call foreign function `sha256_compress` on OS `linux` + fn console_appearance_does_not_affect_the_rollout_hash() { + // Appearance reaches only the console deployment, so it must not roll + // environmentd. + let mut mz = super::v1::Materialize { + spec: super::v1::MaterializeSpec { + environmentd_image_ref: "materialize/environmentd:v26.0.0".to_owned(), + ..Default::default() + }, + metadata: ObjectMeta::default(), + status: None, + }; + + // An unset appearance serializes away entirely, so instances that + // don't configure one hash the same as they did before the field + // existed and are not rolled by adopting a new operator. + let spec = serde_json::to_value(&mz.spec).unwrap(); + assert!(!spec.as_object().unwrap().contains_key("consoleAppearance")); + + let hash = mz.generate_rollout_hash(); + mz.spec.console_appearance = Some(ConsoleAppearance { + display_name: Some("prod".to_owned()), + accent_color: Some(ConsoleAccentColor::Orange), + }); + assert_eq!(mz.generate_rollout_hash(), hash); + } } diff --git a/src/orchestratord/src/controller/console.rs b/src/orchestratord/src/controller/console.rs index 173bef895c2e1..c6b45129e62bd 100644 --- a/src/orchestratord/src/controller/console.rs +++ b/src/orchestratord/src/controller/console.rs @@ -43,7 +43,7 @@ use crate::{ tls::{DefaultCertificateSpecs, create_certificate, issuer_ref_defined}, }; use mz_cloud_resources::crd::{ - ManagedResource, + ConsoleAppearance, ManagedResource, console::v1alpha1::{Console, HttpConnectionScheme}, generated::cert_manager::certificates::{Certificate, CertificatePrivateKeyAlgorithm}, }; @@ -77,6 +77,8 @@ struct AppConfig { auth: AppConfigAuth, #[serde(default, skip_serializing_if = "Option::is_none")] balancerd_dns_names: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + appearance: Option, } #[derive(Serialize)] @@ -239,6 +241,7 @@ impl Context { auth: AppConfigAuth { mode: console.spec.authenticator_kind, }, + appearance: console.spec.appearance.clone(), }) .expect("known valid"); ConfigMap { diff --git a/src/orchestratord/src/controller/materialize.rs b/src/orchestratord/src/controller/materialize.rs index 177b339834b9f..068e0a8795d26 100644 --- a/src/orchestratord/src/controller/materialize.rs +++ b/src/orchestratord/src/controller/materialize.rs @@ -865,6 +865,7 @@ impl k8s_controller::Context for Context { ), resource_requirements: mz.spec.console_resource_requirements.clone(), replicas: Some(mz.console_replicas()), + appearance: mz.spec.console_appearance.clone(), external_certificate_spec: mz.spec.console_external_certificate_spec.clone(), pod_annotations: mz.spec.pod_annotations.clone(), pod_labels: mz.spec.pod_labels.clone(), diff --git a/test/orchestratord/mzcompose.py b/test/orchestratord/mzcompose.py index 1b5a88b08d58c..d91d329f7b9af 100644 --- a/test/orchestratord/mzcompose.py +++ b/test/orchestratord/mzcompose.py @@ -1744,6 +1744,46 @@ def check() -> None: retry(check, 360) +class ConsoleAppearance(Modification): + # The operator copies the Materialize CR's `consoleAppearance` into the + # console's `app-config.json`, which is how the console learns which + # instance it is pointed at. + APPEARANCE = {"displayName": "prod", "accentColor": "orange"} + + @classmethod + def values(cls, version: MzVersion) -> list[Any]: + return [None, cls.APPEARANCE] + + @classmethod + def default(cls) -> Any: + return None + + def modify(self, definition: dict[str, Any]) -> None: + if self.value is not None: + definition["materialize"]["spec"]["consoleAppearance"] = self.value + + def validate(self, mods: dict[type[Modification], Any]) -> None: + # `consoleAppearance` was added in v26.41; older orchestratord builds + # drop the field. + if MzVersion.parse_mz(mods[EnvironmentdImageRef]) < MzVersion.parse_mz( + "v26.41.0-dev.0" + ): + return + # Without a console there's no app config to inspect. + if not mods[ConsoleEnabled]: + return + + def check() -> None: + app_config = get_console_app_config() + actual = app_config.get("appearance") + assert ( + actual == self.value + ), f"Expected appearance {self.value}, but got {actual}: {app_config}" + + # The console is reconciled last and the configmap update is async. + retry(check, 360) + + class RecommendedK8sLabels(Modification): @classmethod def values(cls, version: MzVersion) -> list[Any]: