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:
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user