ui(env): explicit switcher button+modal, forced selection, 3px color bar
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m6s
CI / docker (push) Successful in 1m18s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s

- Replace EnvironmentSelector "All Envs" dropdown with Button+Modal (DS Modal, forced on first-use).
- Add 8-swatch preset color picker in the Environment settings "Appearance" section; commits via useUpdateEnvironment.
- Render a 3px fixed top bar in the current env's color across every page (z-index 900, below DS modals).
- New env-colors tokens (--env-color-*, light + dark) and envColorVar() helper with slate fallback.
- Vitest coverage for button, modal, and color helpers (13 new specs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-22 19:24:48 +02:00
parent 79fa4c097c
commit 2835d08418
16 changed files with 593 additions and 38 deletions

View File

@@ -25,6 +25,8 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments
- `ui/src/auth/auth-store.ts` — Zustand: accessToken, user, roles, login/logout
- `ui/src/api/environment-store.ts` — Zustand: selected environment (localStorage)
- `ui/src/components/ContentTabs.tsx` — main tab switcher
- `ui/src/components/EnvironmentSwitcherButton.tsx` + `EnvironmentSwitcherModal.tsx` — explicit env picker (button in TopBar; DS `Modal`-based list). Replaces the retired `EnvironmentSelector` (All-Envs dropdown). When `envRecords.length > 0` and the stored `selectedEnv` no longer matches any env, `LayoutShell` opens the modal in `forced` mode (non-dismissible). Switcher pulls env records from `useEnvironments()` (admin endpoint; readable by VIEWER+).
- `ui/src/components/env-colors.ts` + `ui/src/styles/env-colors.css` — 8-swatch preset palette for the per-environment color indicator. Tokens `--env-color-slate/red/amber/green/teal/blue/purple/pink` are defined for both light and dark themes. `envColorVar(name)` falls back to `slate` for unknown values. `LayoutShell` renders a 3px fixed top bar in the current env's color (z-index 900, below DS modals).
- `ui/src/components/ExecutionDiagram/` — interactive trace view (canvas)
- `ui/src/components/ProcessDiagram/` — ELK-rendered route diagram
- `ui/src/hooks/useScope.ts` — TabKey type, scope inference

View File

@@ -9,6 +9,7 @@ export interface Environment {
enabled: boolean;
defaultContainerConfig: Record<string, unknown>;
jarRetentionCount: number | null;
color: string;
createdAt: string;
}
@@ -22,6 +23,7 @@ export interface UpdateEnvironmentRequest {
displayName: string;
production: boolean;
enabled: boolean;
color?: string;
}
export function useEnvironments() {

View File

@@ -1,4 +0,0 @@
/* Layout wrapper — DS Select handles its own appearance */
.select {
min-width: 100px;
}

View File

@@ -1,30 +0,0 @@
import { useMemo } from 'react';
import { Select } from '@cameleer/design-system';
import styles from './EnvironmentSelector.module.css';
interface EnvironmentSelectorProps {
environments: string[];
value: string | undefined;
onChange: (env: string | undefined) => void;
}
export function EnvironmentSelector({ environments, value, onChange }: EnvironmentSelectorProps) {
if (environments.length === 0) return null;
const options = useMemo(
() => [
{ value: '', label: 'All Envs' },
...environments.map((env) => ({ value: env, label: env })),
],
[environments],
);
return (
<Select
className={styles.select}
options={options}
value={value ?? ''}
onChange={(e) => onChange(e.target.value || undefined)}
/>
);
}

View File

@@ -0,0 +1,44 @@
.button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
height: 32px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.button:hover {
background: var(--bg-surface-hover, var(--bg-surface));
border-color: var(--border-strong, var(--border-subtle));
}
.button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.name {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
color: var(--text-muted);
flex-shrink: 0;
}

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { EnvironmentSwitcherButton } from './EnvironmentSwitcherButton';
import type { Environment } from '../api/queries/admin/environments';
const envs: Environment[] = [
{
id: '11111111-1111-1111-1111-111111111111',
slug: 'dev',
displayName: 'Development',
production: false,
enabled: true,
defaultContainerConfig: {},
jarRetentionCount: 5,
color: 'amber',
createdAt: '2026-04-22T00:00:00Z',
},
{
id: '22222222-2222-2222-2222-222222222222',
slug: 'prod',
displayName: 'Production',
production: true,
enabled: true,
defaultContainerConfig: {},
jarRetentionCount: 10,
color: 'red',
createdAt: '2026-04-22T00:00:00Z',
},
];
describe('EnvironmentSwitcherButton', () => {
it('renders the selected env display name', () => {
render(<EnvironmentSwitcherButton envs={envs} value="dev" onClick={() => {}} />);
expect(screen.getByText('Development')).toBeInTheDocument();
});
it('shows placeholder text when no env is selected', () => {
render(<EnvironmentSwitcherButton envs={envs} value={undefined} onClick={() => {}} />);
expect(screen.getByText(/select environment/i)).toBeInTheDocument();
});
it('fires onClick when pressed', () => {
const onClick = vi.fn();
render(<EnvironmentSwitcherButton envs={envs} value="dev" onClick={onClick} />);
fireEvent.click(screen.getByRole('button', { name: /switch environment/i }));
expect(onClick).toHaveBeenCalledOnce();
});
it('paints the color dot with the env color CSS variable', () => {
const { container } = render(
<EnvironmentSwitcherButton envs={envs} value="prod" onClick={() => {}} />,
);
const dot = container.querySelector('span[aria-hidden]');
expect(dot).toBeTruthy();
expect((dot as HTMLElement).style.background).toContain('env-color-red');
});
});

View File

@@ -0,0 +1,30 @@
import { ChevronDown } from 'lucide-react';
import type { Environment } from '../api/queries/admin/environments';
import { envColorVar } from './env-colors';
import styles from './EnvironmentSwitcherButton.module.css';
interface EnvironmentSwitcherButtonProps {
envs: Environment[];
value: string | undefined;
onClick: () => void;
}
export function EnvironmentSwitcherButton({ envs, value, onClick }: EnvironmentSwitcherButtonProps) {
const current = envs.find((e) => e.slug === value);
const displayName = current?.displayName ?? value ?? 'Select environment';
const color = envColorVar(current?.color);
return (
<button
type="button"
className={styles.button}
onClick={onClick}
aria-label="Switch environment"
aria-haspopup="dialog"
>
<span className={styles.dot} style={{ background: color }} aria-hidden />
<span className={styles.name}>{displayName}</span>
<ChevronDown size={14} className={styles.chevron} aria-hidden />
</button>
);
}

View File

@@ -0,0 +1,82 @@
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 6px;
color: var(--text-primary);
cursor: pointer;
text-align: left;
transition: background 0.15s ease, border-color 0.15s ease;
}
.row:hover {
background: var(--bg-surface-hover, var(--bg-elevated));
border-color: var(--border-strong, var(--border-subtle));
}
.row:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.rowSelected {
border-color: var(--accent);
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.labels {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.name {
font-size: 13px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.slug {
color: var(--text-muted);
margin-top: 2px;
}
.badges {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.check {
color: var(--accent);
flex-shrink: 0;
}
.empty {
padding: 20px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}

View File

@@ -0,0 +1,111 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '@cameleer/design-system';
import type { ReactNode } from 'react';
import { EnvironmentSwitcherModal } from './EnvironmentSwitcherModal';
import type { Environment } from '../api/queries/admin/environments';
function wrap(ui: ReactNode) {
return render(<ThemeProvider>{ui}</ThemeProvider>);
}
const envs: Environment[] = [
{
id: '11111111-1111-1111-1111-111111111111',
slug: 'dev',
displayName: 'Development',
production: false,
enabled: true,
defaultContainerConfig: {},
jarRetentionCount: 5,
color: 'amber',
createdAt: '2026-04-22T00:00:00Z',
},
{
id: '22222222-2222-2222-2222-222222222222',
slug: 'prod',
displayName: 'Production',
production: true,
enabled: true,
defaultContainerConfig: {},
jarRetentionCount: 10,
color: 'red',
createdAt: '2026-04-22T00:00:00Z',
},
];
describe('EnvironmentSwitcherModal', () => {
it('renders one row per env when open', () => {
wrap(
<EnvironmentSwitcherModal
open
onClose={() => {}}
envs={envs}
value="dev"
onPick={() => {}}
/>,
);
expect(screen.getByText('Development')).toBeInTheDocument();
expect(screen.getByText('Production')).toBeInTheDocument();
});
it('calls onPick with slug when a row is clicked', () => {
const onPick = vi.fn();
wrap(
<EnvironmentSwitcherModal
open
onClose={() => {}}
envs={envs}
value="dev"
onPick={onPick}
/>,
);
fireEvent.click(screen.getByRole('option', { name: /production/i }));
expect(onPick).toHaveBeenCalledWith('prod');
});
it('marks the current env with aria-selected', () => {
wrap(
<EnvironmentSwitcherModal
open
onClose={() => {}}
envs={envs}
value="dev"
onPick={() => {}}
/>,
);
const selected = screen.getByRole('option', { name: /development/i });
expect(selected).toHaveAttribute('aria-selected', 'true');
});
it('renders empty state when no envs exist', () => {
wrap(
<EnvironmentSwitcherModal
open
onClose={() => {}}
envs={[]}
value={undefined}
onPick={() => {}}
/>,
);
expect(screen.getByText(/no environments/i)).toBeInTheDocument();
});
it('forced mode swaps the title and ignores onClose', () => {
const onClose = vi.fn();
wrap(
<EnvironmentSwitcherModal
open
onClose={onClose}
envs={envs}
value={undefined}
onPick={() => {}}
forced
/>,
);
expect(screen.getByText(/select an environment/i)).toBeInTheDocument();
// Simulate ESC — DS Modal forwards this to onClose, which we wrapped in a no-op.
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,66 @@
import { Modal, Badge, MonoText } from '@cameleer/design-system';
import { Check } from 'lucide-react';
import type { Environment } from '../api/queries/admin/environments';
import { envColorVar } from './env-colors';
import styles from './EnvironmentSwitcherModal.module.css';
interface EnvironmentSwitcherModalProps {
open: boolean;
onClose: () => void;
envs: Environment[];
value: string | undefined;
onPick: (slug: string) => void;
/** When true, ESC/backdrop do nothing — the user must pick one. */
forced?: boolean;
}
export function EnvironmentSwitcherModal({
open,
onClose,
envs,
value,
onPick,
forced = false,
}: EnvironmentSwitcherModalProps) {
const title = forced ? 'Select an environment' : 'Switch environment';
const handleClose = forced ? () => { /* locked */ } : onClose;
return (
<Modal open={open} onClose={handleClose} title={title} size="sm">
{envs.length === 0 ? (
<div className={styles.empty}>
No environments ask an admin to create one.
</div>
) : (
<ul className={styles.list} role="listbox" aria-label="Environments">
{envs.map((env) => {
const selected = env.slug === value;
return (
<li key={env.id}>
<button
type="button"
className={`${styles.row} ${selected ? styles.rowSelected : ''}`}
onClick={() => onPick(env.slug)}
aria-selected={selected}
role="option"
>
<span className={styles.dot} style={{ background: envColorVar(env.color) }} aria-hidden />
<div className={styles.labels}>
<div className={styles.name}>{env.displayName}</div>
<MonoText size="xs" className={styles.slug}>{env.slug}</MonoText>
</div>
<div className={styles.badges}>
{env.production && <Badge label="PROD" color="error" />}
{!env.production && <Badge label="NON-PROD" color="auto" />}
{!env.enabled && <Badge label="DISABLED" color="warning" />}
</div>
{selected && <Check size={16} className={styles.check} aria-label="current" />}
</button>
</li>
);
})}
</ul>
)}
</Modal>
);
}

View File

@@ -39,7 +39,9 @@ import { useEnvironmentStore } from '../api/environment-store';
import { useState, useMemo, useCallback, useEffect, useRef, createElement } from 'react';
import type { ReactNode } from 'react';
import { ContentTabs } from './ContentTabs';
import { EnvironmentSelector } from './EnvironmentSelector';
import { EnvironmentSwitcherButton } from './EnvironmentSwitcherButton';
import { EnvironmentSwitcherModal } from './EnvironmentSwitcherModal';
import { envColorVar } from './env-colors';
import { useScope } from '../hooks/useScope';
import { formatDuration } from '../utils/format-utils';
import {
@@ -428,6 +430,25 @@ function LayoutContent() {
queryClient.invalidateQueries();
}, [setSelectedEnvRaw, navigate, location.pathname, location.search, queryClient]);
// --- Env switcher modal -------------------------------------------
const [switcherOpen, setSwitcherOpen] = useState(false);
// Force-open the switcher when we have envs loaded but no valid selection.
// This replaces the old "All Envs" fallback: every session must pick one.
const selectionInvalid =
envRecords.length > 0 &&
(selectedEnv === undefined || !envRecords.some((e) => e.slug === selectedEnv));
const switcherForced = selectionInvalid;
useEffect(() => {
if (selectionInvalid) {
if (selectedEnv !== undefined) setSelectedEnvRaw(undefined);
setSwitcherOpen(true);
}
}, [selectionInvalid, selectedEnv, setSelectedEnvRaw]);
const currentEnvRecord = envRecords.find((e) => e.slug === selectedEnv);
const envBarColor = envColorVar(currentEnvRecord?.color);
// --- Section open states ------------------------------------------
const [appsOpen, setAppsOpen] = useState(() => (isAdminPage || isAlertsPage) ? false : readCollapsed(SK_APPS, true));
const [adminOpen, setAdminOpen] = useState(() => isAdminPage ? true : readCollapsed(SK_ADMIN, false));
@@ -954,14 +975,38 @@ function LayoutContent() {
return (
<AppShell sidebar={sidebarElement}>
<div
aria-hidden
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: 3,
background: envBarColor,
zIndex: 900,
pointerEvents: 'none',
}}
/>
<EnvironmentSwitcherModal
open={switcherOpen}
onClose={() => setSwitcherOpen(false)}
envs={envRecords}
value={selectedEnv}
onPick={(slug) => {
setSelectedEnv(slug);
setSwitcherOpen(false);
}}
forced={switcherForced}
/>
<TopBar
breadcrumb={breadcrumb}
environment={
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<EnvironmentSelector
environments={environments}
<EnvironmentSwitcherButton
envs={envRecords}
value={selectedEnv}
onChange={setSelectedEnv}
onClick={() => setSwitcherOpen(true)}
/>
<NotificationBell />
</div>

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { envColorVar, isEnvColor, ENV_COLORS, DEFAULT_ENV_COLOR } from './env-colors';
describe('env-colors', () => {
it('maps known colors to their css variables', () => {
for (const c of ENV_COLORS) {
expect(envColorVar(c)).toBe(`var(--env-color-${c})`);
}
});
it('falls back to slate for unknown values', () => {
expect(envColorVar('neon')).toBe('var(--env-color-slate)');
expect(envColorVar(undefined)).toBe('var(--env-color-slate)');
expect(envColorVar(null)).toBe('var(--env-color-slate)');
expect(envColorVar('')).toBe('var(--env-color-slate)');
});
it('isEnvColor narrows types', () => {
expect(isEnvColor('amber')).toBe(true);
expect(isEnvColor('neon')).toBe(false);
expect(isEnvColor(undefined)).toBe(false);
});
it('default is slate', () => {
expect(DEFAULT_ENV_COLOR).toBe('slate');
});
});

View File

@@ -0,0 +1,29 @@
// Preset palette for per-environment color indicator. Mirrors the backend
// EnvironmentColor enum. Add/remove entries here AND in:
// - backend V*__add_environment_color.sql CHECK constraint
// - cameleer-server-core/.../EnvironmentColor.java VALUES set
// - ui/src/styles/env-colors.css (token definitions)
export const ENV_COLORS = [
'slate',
'red',
'amber',
'green',
'teal',
'blue',
'purple',
'pink',
] as const;
export type EnvColor = (typeof ENV_COLORS)[number];
export const DEFAULT_ENV_COLOR: EnvColor = 'slate';
export function isEnvColor(v: string | null | undefined): v is EnvColor {
return typeof v === 'string' && (ENV_COLORS as readonly string[]).includes(v);
}
// Safe for any string input — unknown/missing values resolve to the default
// token. Use this whenever rendering an env's color; never concat directly.
export function envColorVar(color: string | null | undefined): string {
return `var(--env-color-${isEnvColor(color) ? color : DEFAULT_ENV_COLOR})`;
}

View File

@@ -1,5 +1,6 @@
import '@cameleer/design-system/style.css';
import './index.css';
import './styles/env-colors.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router';

View File

@@ -23,6 +23,8 @@ import {
useUpdateJarRetention,
} from '../../api/queries/admin/environments';
import type { Environment } from '../../api/queries/admin/environments';
import { ENV_COLORS, envColorVar, type EnvColor } from '../../components/env-colors';
import { Check } from 'lucide-react';
import { PageLoader } from '../../components/PageLoader';
import styles from './UserManagement.module.css';
import sectionStyles from '../../styles/section-card.module.css';
@@ -120,6 +122,7 @@ export default function EnvironmentsPage() {
displayName: newName,
production: selected.production,
enabled: selected.enabled,
color: selected.color,
});
toast({ title: 'Environment renamed', variant: 'success' });
} catch {
@@ -135,6 +138,7 @@ export default function EnvironmentsPage() {
displayName: selected.displayName,
production: value,
enabled: selected.enabled,
color: selected.color,
});
toast({ title: value ? 'Marked as production' : 'Marked as non-production', variant: 'success' });
} catch {
@@ -150,6 +154,7 @@ export default function EnvironmentsPage() {
displayName: selected.displayName,
production: selected.production,
enabled: value,
color: selected.color,
});
toast({ title: value ? 'Environment enabled' : 'Environment disabled', variant: 'success' });
} catch {
@@ -157,6 +162,22 @@ export default function EnvironmentsPage() {
}
}
async function handleColorChange(color: EnvColor) {
if (!selected || selected.color === color) return;
try {
await updateEnv.mutateAsync({
slug: selected.slug,
displayName: selected.displayName,
production: selected.production,
enabled: selected.enabled,
color,
});
toast({ title: 'Environment color updated', variant: 'success' });
} catch {
toast({ title: 'Failed to update color', variant: 'error', duration: 86_400_000 });
}
}
if (isLoading) return <PageLoader />;
return (
@@ -279,6 +300,44 @@ export default function EnvironmentsPage() {
</div>
</div>
<div className={sectionStyles.section}>
<SectionHeader>Appearance</SectionHeader>
<p className={styles.inheritedNote}>
This color is shown as a 3px bar across every page while this environment is active.
</p>
<div role="radiogroup" aria-label="Environment color" style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{ENV_COLORS.map((c) => {
const isSelected = selected.color === c;
return (
<button
key={c}
type="button"
role="radio"
aria-checked={isSelected}
aria-label={c}
title={c}
onClick={() => handleColorChange(c)}
style={{
width: 36,
height: 36,
borderRadius: '50%',
background: envColorVar(c),
border: isSelected ? '2px solid var(--text-primary)' : '1px solid var(--border-subtle)',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
padding: 0,
}}
>
{isSelected && <Check size={16} aria-hidden />}
</button>
);
})}
</div>
</div>
<div className={sectionStyles.section}>
<SectionHeader>Status</SectionHeader>
<div className={styles.securitySection}>

View File

@@ -0,0 +1,34 @@
/*
* Per-environment preset color tokens. Used by:
* - LayoutShell's 3px top-bar indicator
* - EnvironmentSwitcherButton / EnvironmentSwitcherModal (color dot)
* - EnvironmentsPage "Appearance" swatch grid
*
* Light-mode and dark-mode values are tuned for WCAG-AA contrast against the
* respective surface tokens (`--bg-surface`, `--text-primary`).
*
* Storage: plain lowercase string ("slate", "red", ...). Unknown values fall
* back to `slate` via `envColorVar()`.
*/
:root {
--env-color-slate: #94a3b8;
--env-color-red: #ef4444;
--env-color-amber: #f59e0b;
--env-color-green: #10b981;
--env-color-teal: #14b8a6;
--env-color-blue: #3b82f6;
--env-color-purple: #a855f7;
--env-color-pink: #ec4899;
}
[data-theme='dark'] {
--env-color-slate: #a1a9b8;
--env-color-red: #f87171;
--env-color-amber: #fbbf24;
--env-color-green: #34d399;
--env-color-teal: #2dd4bf;
--env-color-blue: #60a5fa;
--env-color-purple: #c084fc;
--env-color-pink: #f472b6;
}