env(admin): per-environment color field + V2 migration

- V2__add_environment_color.sql adds a CHECK-constrained VARCHAR color column (default 'slate'); existing rows backfill to slate.
- Environment record + EnvironmentColor constants (8 preset values) flow through repository, service, and admin API.
- UpdateEnvironmentRequest.color nullable: null preserves existing; unknown values → 400.
- ITs cover valid / invalid / null-preserves behaviour; existing Environment constructor call-sites updated with the new color arg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-22 19:24:30 +02:00
parent 88b003d4f0
commit c2eab71a31
15 changed files with 147 additions and 18 deletions

View File

@@ -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) {}
}

View File

@@ -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()
);
}

View File

@@ -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'));

View File

@@ -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);
}

View File

@@ -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));
}

View File

@@ -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));
}

View File

@@ -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));
}

View File

@@ -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) {

View File

@@ -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