Compare commits
5 Commits
eda74b7339
...
2835d08418
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2835d08418 | ||
|
|
79fa4c097c | ||
|
|
c2eab71a31 | ||
|
|
88b003d4f0 | ||
|
|
e6dcad1e07 |
@@ -72,7 +72,7 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
|
||||
|
||||
### Env admin (env-slug-parameterized, not env-scoped data)
|
||||
|
||||
- `EnvironmentAdminController` — `/api/v1/admin/environments`. GET list / POST create / GET `{envSlug}` / PUT `{envSlug}` / DELETE `{envSlug}` / PUT `{envSlug}/default-container-config` / PUT `{envSlug}/jar-retention`. Slug immutable — PUT body has no slug field; any slug supplied is dropped by Jackson. Slug validated on POST.
|
||||
- `EnvironmentAdminController` — `/api/v1/admin/environments`. GET list / POST create / GET `{envSlug}` / PUT `{envSlug}` / DELETE `{envSlug}` / PUT `{envSlug}/default-container-config` / PUT `{envSlug}/jar-retention`. Slug immutable — PUT body has no slug field; any slug supplied is dropped by Jackson. Slug validated on POST. `UpdateEnvironmentRequest` carries `color` (nullable); unknown values rejected with 400 via `EnvironmentColor.isValid`. Null/absent color preserves the existing value.
|
||||
|
||||
### Agent-only (JWT-authoritative, intentionally flat)
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ paths:
|
||||
|
||||
- `App` — record: id, environmentId, slug, displayName, containerConfig (JSONB)
|
||||
- `AppVersion` — record: id, appId, version, jarPath, detectedRuntimeType, detectedMainClass
|
||||
- `Environment` — record: id, slug, jarRetentionCount
|
||||
- `Environment` — record: id, slug, displayName, production, enabled, defaultContainerConfig, jarRetentionCount, color, createdAt. `color` is one of the 8 preset palette values validated by `EnvironmentColor.VALUES` and CHECK-constrained in PostgreSQL (V2 migration).
|
||||
- `EnvironmentColor` — constants: `DEFAULT = "slate"`, `VALUES = {slate,red,amber,green,teal,blue,purple,pink}`, `isValid(String)`.
|
||||
- `Deployment` — record: id, appId, appVersionId, environmentId, status, targetState, deploymentStrategy, replicaStates (JSONB), deployStage, containerId, containerName
|
||||
- `DeploymentStatus` — enum: STOPPED, STARTING, RUNNING, DEGRADED, STOPPING, FAILED
|
||||
- `DeployStage` — enum: PRE_FLIGHT, PULL_IMAGE, CREATE_NETWORK, START_REPLICAS, HEALTH_CHECK, SWAP_TRAFFIC, COMPLETE
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.cameleer.server.app.controller;
|
||||
|
||||
import com.cameleer.server.core.runtime.Environment;
|
||||
import com.cameleer.server.core.runtime.EnvironmentColor;
|
||||
import com.cameleer.server.core.runtime.EnvironmentService;
|
||||
import com.cameleer.server.core.runtime.RuntimeType;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -58,16 +59,22 @@ public class EnvironmentAdminController {
|
||||
}
|
||||
|
||||
@PutMapping("/{envSlug}")
|
||||
@Operation(summary = "Update an environment's mutable fields (displayName, production, enabled)",
|
||||
@Operation(summary = "Update an environment's mutable fields (displayName, production, enabled, color)",
|
||||
description = "Slug is immutable after creation and cannot be changed. "
|
||||
+ "Any slug field in the request body is ignored.")
|
||||
+ "Any slug field in the request body is ignored. "
|
||||
+ "If color is null or absent, the existing color is preserved.")
|
||||
@ApiResponse(responseCode = "200", description = "Environment updated")
|
||||
@ApiResponse(responseCode = "400", description = "Unknown color value")
|
||||
@ApiResponse(responseCode = "404", description = "Environment not found")
|
||||
public ResponseEntity<?> updateEnvironment(@PathVariable String envSlug,
|
||||
@RequestBody UpdateEnvironmentRequest request) {
|
||||
try {
|
||||
Environment current = environmentService.getBySlug(envSlug);
|
||||
environmentService.update(current.id(), request.displayName(), request.production(), request.enabled());
|
||||
String nextColor = request.color() == null ? current.color() : request.color();
|
||||
if (!EnvironmentColor.isValid(nextColor)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "unknown environment color: " + request.color()));
|
||||
}
|
||||
environmentService.update(current.id(), request.displayName(), request.production(), request.enabled(), nextColor);
|
||||
return ResponseEntity.ok(environmentService.getBySlug(envSlug));
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (e.getMessage().contains("not found")) {
|
||||
@@ -149,6 +156,6 @@ public class EnvironmentAdminController {
|
||||
}
|
||||
|
||||
public record CreateEnvironmentRequest(String slug, String displayName, boolean production) {}
|
||||
public record UpdateEnvironmentRequest(String displayName, boolean production, boolean enabled) {}
|
||||
public record UpdateEnvironmentRequest(String displayName, boolean production, boolean enabled, String color) {}
|
||||
public record JarRetentionRequest(Integer jarRetentionCount) {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.cameleer.server.app.storage;
|
||||
|
||||
import com.cameleer.server.core.runtime.Environment;
|
||||
import com.cameleer.server.core.runtime.EnvironmentColor;
|
||||
import com.cameleer.server.core.runtime.EnvironmentRepository;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -24,7 +25,8 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
private static final String SELECT_COLS = "id, slug, display_name, production, enabled, default_container_config, jar_retention_count, created_at";
|
||||
private static final String SELECT_COLS =
|
||||
"id, slug, display_name, production, enabled, default_container_config, jar_retention_count, color, created_at";
|
||||
|
||||
@Override
|
||||
public List<Environment> findAll() {
|
||||
@@ -58,9 +60,9 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update(UUID id, String displayName, boolean production, boolean enabled) {
|
||||
jdbc.update("UPDATE environments SET display_name = ?, production = ?, enabled = ?, updated_at = now() WHERE id = ?",
|
||||
displayName, production, enabled, id);
|
||||
public void update(UUID id, String displayName, boolean production, boolean enabled, String color) {
|
||||
jdbc.update("UPDATE environments SET display_name = ?, production = ?, enabled = ?, color = ?, updated_at = now() WHERE id = ?",
|
||||
displayName, production, enabled, color, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -93,6 +95,10 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
|
||||
} catch (Exception e) { /* use empty default */ }
|
||||
int retentionRaw = rs.getInt("jar_retention_count");
|
||||
Integer jarRetentionCount = rs.wasNull() ? null : retentionRaw;
|
||||
String color = rs.getString("color");
|
||||
if (color == null || color.isBlank()) {
|
||||
color = EnvironmentColor.DEFAULT;
|
||||
}
|
||||
return new Environment(
|
||||
UUID.fromString(rs.getString("id")),
|
||||
rs.getString("slug"),
|
||||
@@ -101,6 +107,7 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
|
||||
rs.getBoolean("enabled"),
|
||||
config,
|
||||
jarRetentionCount,
|
||||
color,
|
||||
rs.getTimestamp("created_at").toInstant()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ spring:
|
||||
mvc:
|
||||
async:
|
||||
request-timeout: -1
|
||||
mustache:
|
||||
check-template-location: false
|
||||
jackson:
|
||||
serialization:
|
||||
write-dates-as-timestamps: false
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- V2: per-environment color for UI indicator
|
||||
-- Added after V1 baseline (2026-04-22). 8-swatch preset palette; default 'slate'.
|
||||
|
||||
ALTER TABLE environments
|
||||
ADD COLUMN color VARCHAR(16) NOT NULL DEFAULT 'slate'
|
||||
CHECK (color IN ('slate','red','amber','green','teal','blue','purple','pink'));
|
||||
@@ -37,7 +37,7 @@ class AgentLifecycleEvaluatorTest {
|
||||
events = mock(AgentEventRepository.class);
|
||||
envRepo = mock(EnvironmentRepository.class);
|
||||
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(
|
||||
new Environment(ENV_ID, ENV_SLUG, "Prod", true, true, Map.of(), 5, Instant.EPOCH)));
|
||||
new Environment(ENV_ID, ENV_SLUG, "Prod", true, true, Map.of(), 5, "slate", Instant.EPOCH)));
|
||||
eval = new AgentLifecycleEvaluator(events, envRepo);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ class ExchangeMatchEvaluatorTest {
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
|
||||
eval = new ExchangeMatchEvaluator(searchIndex, envRepo, props);
|
||||
|
||||
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, null);
|
||||
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null);
|
||||
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env));
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class LogPatternEvaluatorTest {
|
||||
envRepo = mock(EnvironmentRepository.class);
|
||||
eval = new LogPatternEvaluator(logStore, envRepo);
|
||||
|
||||
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, null);
|
||||
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null);
|
||||
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env));
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class RouteMetricEvaluatorTest {
|
||||
envRepo = mock(EnvironmentRepository.class);
|
||||
eval = new RouteMetricEvaluator(statsStore, envRepo);
|
||||
|
||||
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, null);
|
||||
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null);
|
||||
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env));
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ class NotificationContextBuilderTest {
|
||||
// ---- helpers ----
|
||||
|
||||
private Environment env() {
|
||||
return new Environment(ENV_ID, "prod", "Production", true, true, Map.of(), 5, Instant.EPOCH);
|
||||
return new Environment(ENV_ID, "prod", "Production", true, true, Map.of(), 5, "slate", Instant.EPOCH);
|
||||
}
|
||||
|
||||
private AlertRule rule(ConditionKind kind) {
|
||||
|
||||
@@ -88,9 +88,80 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT {
|
||||
assertThat(body.path("displayName").asText()).isEqualTo("Staging");
|
||||
assertThat(body.path("production").asBoolean()).isFalse();
|
||||
assertThat(body.path("enabled").asBoolean()).isTrue();
|
||||
assertThat(body.path("color").asText()).isEqualTo("slate");
|
||||
assertThat(body.has("id")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateEnvironment_withValidColor_persists() throws Exception {
|
||||
restTemplate.exchange(
|
||||
"/api/v1/admin/environments", HttpMethod.POST,
|
||||
new HttpEntity<>("""
|
||||
{"slug": "color-ok", "displayName": "Color OK", "production": false}
|
||||
""", securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/environments/color-ok", HttpMethod.PUT,
|
||||
new HttpEntity<>("""
|
||||
{"displayName": "Color OK", "production": false, "enabled": true, "color": "amber"}
|
||||
""", securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.path("color").asText()).isEqualTo("amber");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateEnvironment_withNullColor_preservesExisting() throws Exception {
|
||||
restTemplate.exchange(
|
||||
"/api/v1/admin/environments", HttpMethod.POST,
|
||||
new HttpEntity<>("""
|
||||
{"slug": "color-preserve", "displayName": "Keep", "production": false}
|
||||
""", securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
// Set color to teal
|
||||
restTemplate.exchange(
|
||||
"/api/v1/admin/environments/color-preserve", HttpMethod.PUT,
|
||||
new HttpEntity<>("""
|
||||
{"displayName": "Keep", "production": false, "enabled": true, "color": "teal"}
|
||||
""", securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
|
||||
// Update without color field → teal preserved
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/environments/color-preserve", HttpMethod.PUT,
|
||||
new HttpEntity<>("""
|
||||
{"displayName": "Still Keep", "production": false, "enabled": true}
|
||||
""", securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.path("displayName").asText()).isEqualTo("Still Keep");
|
||||
assertThat(body.path("color").asText()).isEqualTo("teal");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateEnvironment_withUnknownColor_returns400() throws Exception {
|
||||
restTemplate.exchange(
|
||||
"/api/v1/admin/environments", HttpMethod.POST,
|
||||
new HttpEntity<>("""
|
||||
{"slug": "color-bad", "displayName": "Bad", "production": false}
|
||||
""", securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/environments/color-bad", HttpMethod.PUT,
|
||||
new HttpEntity<>("""
|
||||
{"displayName": "Bad", "production": false, "enabled": true, "color": "neon"}
|
||||
""", securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateEnvironment_asAdmin_returns200() throws Exception {
|
||||
// Create an environment first
|
||||
|
||||
@@ -12,5 +12,6 @@ public record Environment(
|
||||
boolean enabled,
|
||||
Map<String, Object> defaultContainerConfig,
|
||||
Integer jarRetentionCount,
|
||||
String color,
|
||||
Instant createdAt
|
||||
) {}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.cameleer.server.core.runtime;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Preset palette for the per-environment UI color indicator. Stored as a plain
|
||||
* lowercase string on {@link Environment#color()}. The eight values are
|
||||
* CHECK-constrained in PostgreSQL (V2 migration) and validated again here on
|
||||
* the write path so the controller can return a 400 with a readable message.
|
||||
*
|
||||
* <p>Unknown values are silently tolerated on read (the UI falls back to
|
||||
* {@link #DEFAULT}), so a manual DB tweak won't break rendering — but the API
|
||||
* refuses to persist anything outside this set.
|
||||
*/
|
||||
public final class EnvironmentColor {
|
||||
|
||||
public static final String DEFAULT = "slate";
|
||||
|
||||
public static final Set<String> VALUES = Set.of(
|
||||
"slate", "red", "amber", "green", "teal", "blue", "purple", "pink"
|
||||
);
|
||||
|
||||
private EnvironmentColor() {}
|
||||
|
||||
public static boolean isValid(String color) {
|
||||
return color != null && VALUES.contains(color);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ public interface EnvironmentRepository {
|
||||
Optional<Environment> findById(UUID id);
|
||||
Optional<Environment> findBySlug(String slug);
|
||||
UUID create(String slug, String displayName, boolean production);
|
||||
void update(UUID id, String displayName, boolean production, boolean enabled);
|
||||
void update(UUID id, String displayName, boolean production, boolean enabled, String color);
|
||||
void updateDefaultContainerConfig(UUID id, Map<String, Object> defaultContainerConfig);
|
||||
void updateJarRetentionCount(UUID id, Integer jarRetentionCount);
|
||||
void delete(UUID id);
|
||||
|
||||
@@ -43,9 +43,17 @@ public class EnvironmentService {
|
||||
return repo.create(slug, displayName, production);
|
||||
}
|
||||
|
||||
public void update(UUID id, String displayName, boolean production, boolean enabled) {
|
||||
/**
|
||||
* Update mutable environment fields. Color is validated against
|
||||
* {@link EnvironmentColor#VALUES}. Unknown colors raise
|
||||
* {@link IllegalArgumentException}; the controller maps that to 400.
|
||||
*/
|
||||
public void update(UUID id, String displayName, boolean production, boolean enabled, String color) {
|
||||
getById(id); // verify exists
|
||||
repo.update(id, displayName, production, enabled);
|
||||
if (!EnvironmentColor.isValid(color)) {
|
||||
throw new IllegalArgumentException("unknown environment color: " + color);
|
||||
}
|
||||
repo.update(id, displayName, production, enabled, color);
|
||||
}
|
||||
|
||||
public void updateDefaultContainerConfig(UUID id, Map<String, Object> defaultContainerConfig) {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
# Design — explicit environment switcher + per-env color
|
||||
|
||||
**Date:** 2026-04-22
|
||||
|
||||
## Goals
|
||||
|
||||
1. Replace the `EnvironmentSelector` dropdown with an explicit **button + modal** pattern so switching environments is unambiguous.
|
||||
2. Remove the "All Envs" option. Every session has exactly one environment selected.
|
||||
3. On first login (or if the stored selection no longer exists), force the user to pick an env via a non-dismissible modal — no auto-selection.
|
||||
4. Per-environment **color** (8-swatch preset palette, default `slate`), edited on the Environment admin settings page.
|
||||
5. Render a **3px fixed top bar** in the current env's color on every page — a passive reminder of "which environment am I in?"
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Per-user default environment preference (could be added later).
|
||||
- Free-form HEX colors or custom tokens.
|
||||
- Modifying `@cameleer/design-system` — tokens live in a new app-level CSS file.
|
||||
- Animated transitions on bar color change.
|
||||
|
||||
## Palette
|
||||
|
||||
Eight named swatches, stored as plain strings:
|
||||
|
||||
| token | typical use |
|
||||
|---|---|
|
||||
| `slate` (default) | neutral / unset |
|
||||
| `red` | production / high-alert |
|
||||
| `amber` | staging |
|
||||
| `green` | dev success |
|
||||
| `teal` | QA |
|
||||
| `blue` | sandbox |
|
||||
| `purple` | experimental |
|
||||
| `pink` | personal / scratch |
|
||||
|
||||
All 8 have WCAG-AA contrast against both light and dark app surfaces at 3px thickness. Unknown values in the DB (e.g. manual insert of `neon`) fall back to `slate` in the UI; the admin PUT rejects unknown values with HTTP 400.
|
||||
|
||||
## Backend
|
||||
|
||||
### Migration
|
||||
|
||||
New `V2__add_environment_color.sql`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE environments
|
||||
ADD COLUMN color VARCHAR(16) NOT NULL DEFAULT 'slate'
|
||||
CHECK (color IN ('slate','red','amber','green','teal','blue','purple','pink'));
|
||||
```
|
||||
|
||||
Existing rows backfill to `'slate'` via the `DEFAULT`. No data migration otherwise.
|
||||
|
||||
### Domain
|
||||
|
||||
- `Environment` record (core): add `String color`.
|
||||
- New `EnvironmentColor` class in core (`runtime/EnvironmentColor.java`): exposes `Set<String> VALUES` and `boolean isValid(String)`; plus `String DEFAULT = "slate"`.
|
||||
- `EnvironmentRepository` / `PostgresEnvironmentRepository`: select/insert/update include `color`.
|
||||
- `EnvironmentService`:
|
||||
- `create(slug, displayName, production)` unchanged — color comes from DB default.
|
||||
- `update(...)` — signature gains a `String color` parameter.
|
||||
|
||||
### API
|
||||
|
||||
- `EnvironmentAdminController.UpdateEnvironmentRequest` — `+ String color` (nullable).
|
||||
- `PUT /api/v1/admin/environments/{envSlug}`:
|
||||
- `color == null` → preserve existing value.
|
||||
- `color != null && !EnvironmentColor.isValid(color)` → 400 `"unknown environment color: {color}"`.
|
||||
- `color != null && EnvironmentColor.isValid(color)` → persist.
|
||||
- `CreateEnvironmentRequest` intentionally does **not** take a color. New envs always start at `slate`; user picks later on the settings page.
|
||||
- `GET` responses include `color`.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Tokens
|
||||
|
||||
New `ui/src/styles/env-colors.css` imported by `ui/src/main.tsx`. Defines 8 CSS variables for both light and dark themes:
|
||||
|
||||
```css
|
||||
: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;
|
||||
/* adjusted shades with equivalent-or-better contrast on dark surfaces */
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
These tokens live at the app level because `@cameleer/design-system` is consumed as an external npm package (`^0.1.56`). The app owns this vocabulary.
|
||||
|
||||
### Helpers
|
||||
|
||||
`ui/src/components/env-colors.ts`:
|
||||
|
||||
```ts
|
||||
export const ENV_COLORS = ['slate','red','amber','green','teal','blue','purple','pink'] as const;
|
||||
export type EnvColor = typeof ENV_COLORS[number];
|
||||
export function isEnvColor(v: string | undefined): v is EnvColor { ... }
|
||||
export function envColorVar(c: string | undefined): string {
|
||||
return `var(--env-color-${isEnvColor(c) ? c : 'slate'})`;
|
||||
}
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
`ui/src/api/queries/admin/environments.ts`:
|
||||
|
||||
- `Environment.color: string`
|
||||
- `UpdateEnvironmentRequest.color?: string`
|
||||
|
||||
`schema.d.ts` regenerated via `npm run generate-api:live` (backend must be up).
|
||||
|
||||
### Components
|
||||
|
||||
**Delete:**
|
||||
- `ui/src/components/EnvironmentSelector.tsx`
|
||||
- `ui/src/components/EnvironmentSelector.module.css`
|
||||
|
||||
**New:**
|
||||
|
||||
- `ui/src/components/EnvironmentSwitcherButton.tsx`
|
||||
- DS `Button` variant `secondary size="sm"`.
|
||||
- Content: 8px color dot (using `envColorVar(env.color)`) + display name + chevron-down icon.
|
||||
- Click → open modal.
|
||||
|
||||
- `ui/src/components/EnvironmentSwitcherModal.tsx`
|
||||
- Wraps DS `Modal` (size `sm`).
|
||||
- Props: `open`, `onClose`, `envs`, `value`, `onChange`, `forced?: boolean`.
|
||||
- Body: clickable vertical list of rows. Each row: color dot + displayName + slug (mono, muted) + PROD/NON-PROD/DISABLED badges + check indicator when current. Empty state: "No environments — ask an admin to create one."
|
||||
- `forced === true`: `onClose` is a no-op; title changes from "Switch environment" to "Select an environment".
|
||||
|
||||
### LayoutShell wire-up
|
||||
|
||||
`ui/src/components/LayoutShell.tsx`:
|
||||
|
||||
- Replace `<EnvironmentSelector …>` with `<EnvironmentSwitcherButton envs={environments} value={selectedEnv} onChange={setSelectedEnv} />`.
|
||||
- Mount `<EnvironmentSwitcherModal …>` separately.
|
||||
- Render a 3px fixed top bar:
|
||||
```jsx
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0,
|
||||
height: 3, zIndex: 900,
|
||||
background: envColorVar(currentEnvObj?.color),
|
||||
}} aria-hidden />
|
||||
```
|
||||
z-index 900 sits above page content but below DS `Modal` (>= 1000), so modals cover it cleanly.
|
||||
- Effect: if `environments.length > 0 && (selectedEnv === undefined || !environments.some(e => e.slug === selectedEnv))`, clear stale slug and open the modal in **forced** mode. Stays open until the user picks.
|
||||
|
||||
### Admin settings
|
||||
|
||||
`ui/src/pages/Admin/EnvironmentsPage.tsx`:
|
||||
|
||||
- New section **"Appearance"** between Configuration and Status.
|
||||
- Row of 8 circular swatches (36px). Selected swatch: 2px outline in `--text-primary` + small checkmark.
|
||||
- Click → `updateEnv.mutateAsync({ slug, displayName, production, enabled, color })`.
|
||||
- Existing handlers (`handleRename`, `handleToggleProduction`, `handleToggleEnabled`) pass through `selected.color` so they don't wipe it.
|
||||
|
||||
## Data flow
|
||||
|
||||
- Pick env in modal → `useEnvironmentStore.setEnvironment(slug)` → `selectedEnv` in LayoutShell changes → top bar re-renders with new color → env-scoped pages refetch via their `useSelectedEnv` hooks.
|
||||
- Change color in settings → `useUpdateEnvironment` mutation → invalidates `['admin','environments']` → top bar picks up new color on next frame.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **No envs at all** — modal forced, empty state. (Doesn't happen in practice since V1 seeds `default`.)
|
||||
- **Stored slug no longer exists** (admin deleted it mid-session) — LayoutShell effect clears store + opens forced modal.
|
||||
- **Migrating from "All Envs"** (`selectedEnv === undefined` after this change ships) — same as above: forced modal on first post-migration render.
|
||||
- **Bad color in DB** — `envColorVar` falls back to `slate`; admin PUT rejects invalid values with 400.
|
||||
- **Modal open while env deleted externally** — TanStack list updates; previously-selected row silently disappears.
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend
|
||||
|
||||
- `EnvironmentAdminControllerIT`:
|
||||
- Existing tests pass unchanged (default color round-trips).
|
||||
- New: PUT with valid color persists; PUT with unknown color → 400; PUT with null/absent color preserves existing.
|
||||
- `SchemaBootstrapIT` (or equivalent) — asserts `environments.color` exists with default `slate`.
|
||||
- `PostgresEnvironmentRepositoryIT` — if present, covers round-trip.
|
||||
|
||||
### Frontend (Vitest + RTL)
|
||||
|
||||
- `EnvironmentSwitcherButton.test.tsx` — renders dot + name; click opens modal.
|
||||
- `EnvironmentSwitcherModal.test.tsx` — one row per env; click calls `onChange`; `forced=true` ignores ESC/backdrop.
|
||||
- `LayoutShell.test.tsx` — when `selectedEnv` is missing but envs loaded, forced modal mounts; after pick, top bar gets env's color token.
|
||||
- `EnvironmentsPage.test.tsx` — swatch grid renders; click triggers `useUpdateEnvironment` with `{color}` in payload.
|
||||
|
||||
## Rule/doc updates
|
||||
|
||||
- `.claude/rules/app-classes.md` — note `UpdateEnvironmentRequest.color` on env admin controller.
|
||||
- `.claude/rules/core-classes.md` — `Environment` record `color` field.
|
||||
- `.claude/rules/ui.md` — `EnvironmentSwitcherButton` / `EnvironmentSwitcherModal` replace `EnvironmentSelector`; `env-colors.css` location; 3px top bar in LayoutShell.
|
||||
|
||||
## OpenAPI regeneration
|
||||
|
||||
Required per CLAUDE.md: bring backend up on :8081, run `cd ui && npm run generate-api:live`, commit `openapi.json` + `schema.d.ts`.
|
||||
File diff suppressed because one or more lines are too long
@@ -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() {
|
||||
|
||||
15
ui/src/api/schema.d.ts
vendored
15
ui/src/api/schema.d.ts
vendored
@@ -237,8 +237,8 @@ export interface paths {
|
||||
/** Get environment by slug */
|
||||
get: operations["getEnvironment"];
|
||||
/**
|
||||
* Update an environment's mutable fields (displayName, production, enabled)
|
||||
* @description Slug is immutable after creation and cannot be changed. Any slug field in the request body is ignored.
|
||||
* Update an environment's mutable fields (displayName, production, enabled, color)
|
||||
* @description Slug is immutable after creation and cannot be changed. Any slug field in the request body is ignored. If color is null or absent, the existing color is preserved.
|
||||
*/
|
||||
put: operations["updateEnvironment"];
|
||||
post?: never;
|
||||
@@ -2111,6 +2111,7 @@ export interface components {
|
||||
};
|
||||
/** Format: int32 */
|
||||
jarRetentionCount?: number;
|
||||
color?: string;
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
};
|
||||
@@ -2581,6 +2582,7 @@ export interface components {
|
||||
displayName?: string;
|
||||
production?: boolean;
|
||||
enabled?: boolean;
|
||||
color?: string;
|
||||
};
|
||||
JarRetentionRequest: {
|
||||
/** Format: int32 */
|
||||
@@ -4551,6 +4553,15 @@ export interface operations {
|
||||
"*/*": Record<string, never>;
|
||||
};
|
||||
};
|
||||
/** @description Unknown color value */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": Record<string, never>;
|
||||
};
|
||||
};
|
||||
/** @description Environment not found */
|
||||
404: {
|
||||
headers: {
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
/* Layout wrapper — DS Select handles its own appearance */
|
||||
.select {
|
||||
min-width: 100px;
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
44
ui/src/components/EnvironmentSwitcherButton.module.css
Normal file
44
ui/src/components/EnvironmentSwitcherButton.module.css
Normal 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;
|
||||
}
|
||||
57
ui/src/components/EnvironmentSwitcherButton.test.tsx
Normal file
57
ui/src/components/EnvironmentSwitcherButton.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
30
ui/src/components/EnvironmentSwitcherButton.tsx
Normal file
30
ui/src/components/EnvironmentSwitcherButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
ui/src/components/EnvironmentSwitcherModal.module.css
Normal file
82
ui/src/components/EnvironmentSwitcherModal.module.css
Normal 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;
|
||||
}
|
||||
111
ui/src/components/EnvironmentSwitcherModal.test.tsx
Normal file
111
ui/src/components/EnvironmentSwitcherModal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
66
ui/src/components/EnvironmentSwitcherModal.tsx
Normal file
66
ui/src/components/EnvironmentSwitcherModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
27
ui/src/components/env-colors.test.ts
Normal file
27
ui/src/components/env-colors.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
29
ui/src/components/env-colors.ts
Normal file
29
ui/src/components/env-colors.ts
Normal 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})`;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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}>
|
||||
|
||||
34
ui/src/styles/env-colors.css
Normal file
34
ui/src/styles/env-colors.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user