diff --git a/.claude/rules/app-classes.md b/.claude/rules/app-classes.md index bf80c2b0..a2a32b8e 100644 --- a/.claude/rules/app-classes.md +++ b/.claude/rules/app-classes.md @@ -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) diff --git a/.claude/rules/core-classes.md b/.claude/rules/core-classes.md index de7e5df4..2379296e 100644 --- a/.claude/rules/core-classes.md +++ b/.claude/rules/core-classes.md @@ -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 diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java index 06507ad3..1740df5d 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java @@ -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) {} } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresEnvironmentRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresEnvironmentRepository.java index 119c8d53..32376fcf 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresEnvironmentRepository.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresEnvironmentRepository.java @@ -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 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() ); } diff --git a/cameleer-server-app/src/main/resources/db/migration/V2__add_environment_color.sql b/cameleer-server-app/src/main/resources/db/migration/V2__add_environment_color.sql new file mode 100644 index 00000000..537e6529 --- /dev/null +++ b/cameleer-server-app/src/main/resources/db/migration/V2__add_environment_color.sql @@ -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')); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AgentLifecycleEvaluatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AgentLifecycleEvaluatorTest.java index a043c0e5..a46afc02 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AgentLifecycleEvaluatorTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AgentLifecycleEvaluatorTest.java @@ -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); } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluatorTest.java index b923e725..e1b6b913 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluatorTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluatorTest.java @@ -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)); } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluatorTest.java index ea9a586b..c113b3c3 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluatorTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluatorTest.java @@ -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)); } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/RouteMetricEvaluatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/RouteMetricEvaluatorTest.java index 3baf34fe..eae39800 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/RouteMetricEvaluatorTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/RouteMetricEvaluatorTest.java @@ -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)); } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderTest.java index c11b2142..c3e30e14 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderTest.java @@ -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) { diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/EnvironmentAdminControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/EnvironmentAdminControllerIT.java index b4d370fe..b20eb8ab 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/EnvironmentAdminControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/EnvironmentAdminControllerIT.java @@ -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 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 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 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 diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Environment.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Environment.java index 76cb72b3..e4b53f94 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Environment.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Environment.java @@ -12,5 +12,6 @@ public record Environment( boolean enabled, Map defaultContainerConfig, Integer jarRetentionCount, + String color, Instant createdAt ) {} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentColor.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentColor.java new file mode 100644 index 00000000..18805d4e --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentColor.java @@ -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. + * + *

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 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); + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentRepository.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentRepository.java index 2d22c338..e1157005 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentRepository.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentRepository.java @@ -10,7 +10,7 @@ public interface EnvironmentRepository { Optional findById(UUID id); Optional 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 defaultContainerConfig); void updateJarRetentionCount(UUID id, Integer jarRetentionCount); void delete(UUID id); diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java index febd0eb1..4941ab70 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java @@ -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 defaultContainerConfig) {