diff --git a/docs/superpowers/plans/2026-04-23-checkpoints-table-redesign.md b/docs/superpowers/plans/2026-04-23-checkpoints-table-redesign.md new file mode 100644 index 00000000..a738cdba --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-checkpoints-table-redesign.md @@ -0,0 +1,2177 @@ +# Checkpoints table redesign + deployment audit gap — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the cramped Checkpoints disclosure on the deployment page with a real DataTable + side drawer (Logs / Config with snapshot+diff modes), and close the deployment audit-log gap (deploy/stop/promote currently emit zero audit rows). + +**Architecture:** Phase 1 lays a backend foundation (V2 migration adds `created_by` on `deployments`, new `AuditCategory.DEPLOYMENT`, audit calls in `DeploymentController`, `instanceIds` filter on `LogQueryController`). Phase 2 builds a project-local `SideDrawer` primitive. Phase 3 makes the existing config sub-tabs read-only-capable. Phase 4 builds the new `CheckpointsTable` and `CheckpointDetailDrawer`. Phase 5 wires polish (admin filter dropdown, rules docs). + +**Tech Stack:** Java 17, Spring Boot 3.4.3, JdbcTemplate, Flyway, JUnit 5 + Testcontainers (Postgres 16 + ClickHouse 24.12); React 19 + TypeScript, Vite, TanStack Query, Zustand, Vitest + React Testing Library, `@cameleer/design-system`. + +**Spec:** `docs/superpowers/specs/2026-04-23-checkpoints-table-redesign-design.md` + +--- + +## Phase 1 — Backend foundation + +### Task 1: Flyway V2 migration adds `deployments.created_by` + +**Files:** +- Create: `cameleer-server-app/src/main/resources/db/migration/V2__add_deployment_created_by.sql` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/storage/V2DeploymentCreatedByMigrationIT.java` + +- [ ] **Step 1: Write the failing IT** + +```java +package com.cameleer.server.app.storage; + +import com.cameleer.server.app.AbstractPostgresIT; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class V2DeploymentCreatedByMigrationIT extends AbstractPostgresIT { + + @Autowired JdbcTemplate jdbc; + + @Test + void created_by_column_exists_with_correct_type_and_fk() { + List> cols = jdbc.queryForList( + "SELECT column_name, data_type, is_nullable " + + "FROM information_schema.columns " + + "WHERE table_name = 'deployments' AND column_name = 'created_by'" + ); + assertThat(cols).hasSize(1); + assertThat(cols.get(0)).containsEntry("data_type", "text"); + assertThat(cols.get(0)).containsEntry("is_nullable", "YES"); + } + + @Test + void created_by_index_exists() { + Integer count = jdbc.queryForObject( + "SELECT count(*)::int FROM pg_indexes " + + "WHERE tablename = 'deployments' AND indexname = 'idx_deployments_created_by'", + Integer.class + ); + assertThat(count).isEqualTo(1); + } + + @Test + void created_by_has_fk_to_users() { + Integer count = jdbc.queryForObject( + "SELECT count(*)::int FROM information_schema.table_constraints tc " + + "JOIN information_schema.constraint_column_usage ccu " + + " ON tc.constraint_name = ccu.constraint_name " + + "WHERE tc.table_name = 'deployments' " + + " AND tc.constraint_type = 'FOREIGN KEY' " + + " AND ccu.table_name = 'users' " + + " AND ccu.column_name = 'user_id'", + Integer.class + ); + assertThat(count).isGreaterThanOrEqualTo(1); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -pl cameleer-server-app verify -Dit.test=V2DeploymentCreatedByMigrationIT` +Expected: tests FAIL — column does not exist. + +- [ ] **Step 3: Create the migration file** + +`cameleer-server-app/src/main/resources/db/migration/V2__add_deployment_created_by.sql`: + +```sql +ALTER TABLE deployments + ADD COLUMN created_by TEXT REFERENCES users(user_id); + +CREATE INDEX idx_deployments_created_by ON deployments (created_by); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -pl cameleer-server-app verify -Dit.test=V2DeploymentCreatedByMigrationIT` +Expected: all 3 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add cameleer-server-app/src/main/resources/db/migration/V2__add_deployment_created_by.sql \ + cameleer-server-app/src/test/java/com/cameleer/server/app/storage/V2DeploymentCreatedByMigrationIT.java +git commit -m "feat(deploy): V2 migration — add created_by to deployments" +``` + +--- + +### Task 2: Add `AuditCategory.DEPLOYMENT` + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java` +- Modify: `cameleer-server-core/src/test/java/com/cameleer/server/core/admin/AuditCategoryTest.java` + +- [ ] **Step 1: Write/update the failing test** + +In `AuditCategoryTest.java`, add (or merge with the existing exhaustive enumeration test): + +```java +@Test +void deployment_category_exists() { + assertThat(AuditCategory.valueOf("DEPLOYMENT")) + .isEqualTo(AuditCategory.DEPLOYMENT); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -pl cameleer-server-core test -Dtest=AuditCategoryTest` +Expected: FAIL — `DEPLOYMENT` not declared. + +- [ ] **Step 3: Add the enum value** + +Edit `AuditCategory.java`: + +```java +package com.cameleer.server.core.admin; + +public enum AuditCategory { + INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT, + OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE, + ALERT_RULE_CHANGE, ALERT_SILENCE_CHANGE, + DEPLOYMENT +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -pl cameleer-server-core test -Dtest=AuditCategoryTest` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java \ + cameleer-server-core/src/test/java/com/cameleer/server/core/admin/AuditCategoryTest.java +git commit -m "feat(audit): add DEPLOYMENT audit category" +``` + +--- + +### Task 3: Extend `Deployment` record + `DeploymentService` signature with `createdBy` + +This task is the largest in Phase 1 because the field ripples through one record + one interface + one service impl + one repository + many tests. Allocate ~30 min. Run the full unit-test suite at the end to catch ripple breaks. + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Deployment.java` +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentService.java` +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentServiceImpl.java` (or wherever the impl lives — verify with `Grep "class DeploymentServiceImpl|implements DeploymentService"`) +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java` +- Modify: any other constructor call sites surfaced by the compiler (the compiler is your todo list) +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/storage/PostgresDeploymentRepositoryCreatedByIT.java` + +- [ ] **Step 1: Write the failing IT** + +```java +package com.cameleer.server.app.storage; + +import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.core.runtime.Deployment; +import com.cameleer.server.core.runtime.DeploymentService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class PostgresDeploymentRepositoryCreatedByIT extends AbstractPostgresIT { + + @Autowired DeploymentService deploymentService; + @Autowired JdbcTemplate jdbc; + + private UUID appId; + private UUID envId; + private UUID versionId; + + @BeforeEach + void seedAppAndVersion() { + // Use the seeded default environment from V1 + envId = jdbc.queryForObject( + "SELECT id FROM environments WHERE slug = 'default'", UUID.class); + // Seed user + jdbc.update("INSERT INTO users (user_id, password_hash, source) VALUES (?, '', 'LOCAL') " + + "ON CONFLICT (user_id) DO NOTHING", "alice"); + // Seed app + appId = UUID.randomUUID(); + jdbc.update("INSERT INTO apps (id, environment_id, app_slug, display_name, container_config) " + + "VALUES (?, ?, 'test-app', 'Test App', '{}'::jsonb)", + appId, envId); + // Seed version + versionId = UUID.randomUUID(); + jdbc.update("INSERT INTO app_versions (id, app_id, version, jar_path, jar_checksum, " + + "jar_filename, jar_size_bytes) " + + "VALUES (?, ?, 1, '/tmp/x.jar', 'abc', 'x.jar', 100)", + versionId, appId); + } + + @Test + void createDeployment_persists_createdBy_and_returns_it() { + Deployment d = deploymentService.createDeployment(appId, versionId, envId, "alice"); + + assertThat(d.createdBy()).isEqualTo("alice"); + + String fromDb = jdbc.queryForObject( + "SELECT created_by FROM deployments WHERE id = ?", String.class, d.id()); + assertThat(fromDb).isEqualTo("alice"); + } + + @Test + void promote_persists_createdBy() { + Deployment promoted = deploymentService.promote(appId, versionId, envId, "bob"); + + assertThat(promoted.createdBy()).isEqualTo("bob"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -pl cameleer-server-app verify -Dit.test=PostgresDeploymentRepositoryCreatedByIT` +Expected: COMPILE FAIL — `Deployment.createdBy()` doesn't exist; `createDeployment(...)` arity wrong. + +- [ ] **Step 3: Add `createdBy` to the `Deployment` record** + +Edit `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Deployment.java`. The current record carries id, appId, appVersionId, environmentId, status, etc. Add `String createdBy` as the **last** field (additive, keeps the existing positional order intact for the rest): + +```java +public record Deployment( + UUID id, + UUID appId, + UUID appVersionId, + UUID environmentId, + DeploymentStatus status, + String targetState, + String deploymentStrategy, + DeployStage deployStage, + List replicaStates, + Map resolvedConfig, + String containerId, + String containerName, + String errorMessage, + Instant deployedAt, + Instant stoppedAt, + Instant createdAt, + DeploymentConfigSnapshot deployedConfigSnapshot, + String createdBy +) {} +``` + +(If your local `Deployment.java` has a different exact field order, preserve it and only **append** `String createdBy` at the end.) + +- [ ] **Step 4: Update `DeploymentService` interface** + +Edit `DeploymentService.java`. Change the two methods: + +```java +Deployment createDeployment(UUID appId, UUID appVersionId, UUID environmentId, String createdBy); + +Deployment promote(UUID targetAppId, UUID sourceVersionId, UUID targetEnvironmentId, String createdBy); +``` + +- [ ] **Step 5: Update the service impl** + +Locate the impl (`Grep "implements DeploymentService"` if unsure). Update the two methods to accept and forward `createdBy` to the repository. + +- [ ] **Step 6: Update `PostgresDeploymentRepository`** + +In the `INSERT INTO deployments(...) VALUES(...)` statement, add `created_by` to the column list and `?` to the values list, binding the new `createdBy` parameter. In the `RowMapper` for `Deployment`, read `rs.getString("created_by")` and pass it as the new last constructor arg. + +- [ ] **Step 7: Compile to find ripple** + +Run: `mvn -pl cameleer-server-app -am compile` +Expected: list of broken call sites (`DeploymentController`, tests). Add the new arg at every call site — for now, in tests, pass `"test-user"`. The controller fix is Task 4; for now, hardcode `"test-user"` there too so the build compiles. + +- [ ] **Step 8: Run the failing IT** + +Run: `mvn -pl cameleer-server-app verify -Dit.test=PostgresDeploymentRepositoryCreatedByIT` +Expected: PASS. + +- [ ] **Step 9: Run all unit tests to catch ripple regressions** + +Run: `mvn -pl cameleer-server-app verify -DskipITs` +Expected: 205+ tests pass (matches Phase 0 baseline). + +- [ ] **Step 10: Commit** + +```bash +git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Deployment.java \ + cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentService.java \ + cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentServiceImpl.java \ + cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java \ + cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java \ + cameleer-server-app/src/test/java/com/cameleer/server/app/storage/PostgresDeploymentRepositoryCreatedByIT.java +# Plus any other modified test files surfaced by the compiler +git commit -m "feat(deploy): add createdBy to Deployment record + service + repo" +``` + +--- + +### Task 4: `DeploymentController` — audit calls + resolve `createdBy` from `SecurityContextHolder` + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerAuditIT.java` + +- [ ] **Step 1: Write the failing IT** + +```java +package com.cameleer.server.app.controller; + +import com.cameleer.server.app.AbstractPostgresIT; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class DeploymentControllerAuditIT extends AbstractPostgresIT { + + @Autowired TestRestTemplate rest; + @Autowired JdbcTemplate jdbc; + // Helper to mint an OPERATOR JWT for "alice" — copy the pattern used by + // an existing controller IT (e.g. AppControllerIT). If a shared helper + // exists (e.g. JwtTestSupport), use it. + + private UUID envId; + private UUID appId; + private UUID versionId; + private String aliceJwt; + + @BeforeEach + void seed() { + // Same seed pattern as PostgresDeploymentRepositoryCreatedByIT. + // Plus: mint aliceJwt as a user with ROLE_OPERATOR. + } + + @Test + void deploy_writes_audit_row_with_DEPLOYMENT_category_and_alice_actor() { + HttpHeaders h = new HttpHeaders(); + h.setBearerAuth(aliceJwt); + var body = Map.of("appVersionId", versionId.toString()); + var resp = rest.exchange( + "/api/v1/environments/default/apps/test-app/deployments", + HttpMethod.POST, new HttpEntity<>(body, h), Map.class); + + assertThat(resp.getStatusCode().is2xxSuccessful()).isTrue(); + + List> rows = jdbc.queryForList( + "SELECT actor, action, category, target_id, result " + + "FROM audit_log WHERE action = 'deploy_app' ORDER BY ts DESC LIMIT 1"); + assertThat(rows).hasSize(1); + assertThat(rows.get(0)) + .containsEntry("actor", "alice") + .containsEntry("action", "deploy_app") + .containsEntry("category", "DEPLOYMENT") + .containsEntry("result", "SUCCESS"); + assertThat((String) rows.get(0).get("target_id")).isNotBlank(); + } + + @Test + void stop_writes_audit_row() { + // ... arrange a deployment, hit POST /{id}/stop, assert audit_log row + } + + @Test + void promote_writes_audit_row() { + // ... arrange another env, hit POST /{id}/promote, assert audit_log row + } + + @Test + void deploy_with_unknown_app_writes_FAILURE_audit_row() { + // 404 path — assert audit_log row with result=FAILURE + } +} +``` + +> Note: If your existing controller ITs use a different test framework/helper for JWT minting, follow that pattern instead. The above is a sketch — adapt to local conventions. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -pl cameleer-server-app verify -Dit.test=DeploymentControllerAuditIT` +Expected: tests FAIL — no audit rows written. + +- [ ] **Step 3: Wire `AuditService` + `createdBy` into `DeploymentController`** + +Edit `DeploymentController.java`: + +```java +import com.cameleer.server.core.admin.AuditService; +import com.cameleer.server.core.admin.AuditCategory; +import com.cameleer.server.core.admin.AuditResult; +import org.springframework.security.core.context.SecurityContextHolder; +import jakarta.servlet.http.HttpServletRequest; + +private final AuditService auditService; + +// Add to constructor params + assignment. + +private static String currentUserId() { + String name = SecurityContextHolder.getContext().getAuthentication().getName(); + return name != null && name.startsWith("user:") ? name.substring(5) : name; +} +``` + +For `deploy(...)`: + +```java +@PostMapping +public ResponseEntity deploy(@EnvPath Environment env, + @PathVariable String appSlug, + @RequestBody DeployRequest request, + HttpServletRequest httpRequest) { + String createdBy = currentUserId(); + try { + App app = appService.getByEnvironmentAndSlug(env.id(), appSlug); + Deployment deployment = deploymentService.createDeployment( + app.id(), request.appVersionId(), env.id(), createdBy); + deploymentExecutor.executeAsync(deployment); + + // Look up version for audit details + AppVersion version = appVersionService.getById(request.appVersionId()); + auditService.log(createdBy, "deploy_app", AuditCategory.DEPLOYMENT, + deployment.id().toString(), + Map.of("appSlug", appSlug, "envSlug", env.slug(), + "appVersionId", request.appVersionId().toString(), + "jarFilename", version.jarFilename(), + "version", version.version()), + AuditResult.SUCCESS, httpRequest); + return ResponseEntity.accepted().body(deployment); + } catch (IllegalArgumentException e) { + auditService.log(createdBy, "deploy_app", AuditCategory.DEPLOYMENT, + null, Map.of("appSlug", appSlug, "envSlug", env.slug(), + "error", e.getMessage()), + AuditResult.FAILURE, httpRequest); + return ResponseEntity.notFound().build(); + } +} +``` + +For `stop(...)`: + +```java +@PostMapping("/{deploymentId}/stop") +public ResponseEntity stop(@EnvPath Environment env, + @PathVariable String appSlug, + @PathVariable UUID deploymentId, + HttpServletRequest httpRequest) { + String actor = currentUserId(); + try { + Deployment deployment = deploymentService.getById(deploymentId); + deploymentExecutor.stopDeployment(deployment); + auditService.log(actor, "stop_deployment", AuditCategory.DEPLOYMENT, + deploymentId.toString(), + Map.of("appSlug", appSlug, "envSlug", env.slug()), + AuditResult.SUCCESS, httpRequest); + return ResponseEntity.ok(deploymentService.getById(deploymentId)); + } catch (IllegalArgumentException e) { + auditService.log(actor, "stop_deployment", AuditCategory.DEPLOYMENT, + deploymentId.toString(), + Map.of("appSlug", appSlug, "envSlug", env.slug(), "error", e.getMessage()), + AuditResult.FAILURE, httpRequest); + return ResponseEntity.notFound().build(); + } +} +``` + +For `promote(...)`: + +```java +@PostMapping("/{deploymentId}/promote") +public ResponseEntity promote(@EnvPath Environment env, + @PathVariable String appSlug, + @PathVariable UUID deploymentId, + @RequestBody PromoteRequest request, + HttpServletRequest httpRequest) { + String actor = currentUserId(); + try { + App sourceApp = appService.getByEnvironmentAndSlug(env.id(), appSlug); + Deployment source = deploymentService.getById(deploymentId); + Environment targetEnv = environmentService.getBySlug(request.targetEnvironment()); + App targetApp = appService.getByEnvironmentAndSlug(targetEnv.id(), appSlug); + Deployment promoted = deploymentService.promote( + targetApp.id(), source.appVersionId(), targetEnv.id(), actor); + deploymentExecutor.executeAsync(promoted); + + auditService.log(actor, "promote_deployment", AuditCategory.DEPLOYMENT, + promoted.id().toString(), + Map.of("sourceEnv", env.slug(), "targetEnv", targetEnv.slug(), + "appSlug", appSlug, + "appVersionId", source.appVersionId().toString()), + AuditResult.SUCCESS, httpRequest); + return ResponseEntity.accepted().body(promoted); + } catch (IllegalArgumentException e) { + auditService.log(actor, "promote_deployment", AuditCategory.DEPLOYMENT, + deploymentId.toString(), + Map.of("appSlug", appSlug, "error", e.getMessage()), + AuditResult.FAILURE, httpRequest); + return ResponseEntity.status(org.springframework.http.HttpStatus.NOT_FOUND) + .body(Map.of("error", e.getMessage())); + } +} +``` + +> If the precise `AuditService.log(...)` overload signature differs from the example above, match the existing usage pattern in `OutboundConnectionAdminController` (line 85+) — it's the closest analog. + +- [ ] **Step 4: Run the IT** + +Run: `mvn -pl cameleer-server-app verify -Dit.test=DeploymentControllerAuditIT` +Expected: all 4 tests PASS. + +- [ ] **Step 5: Run the full IT suite to catch other DeploymentController tests that broke** + +Run: `mvn -pl cameleer-server-app verify -Dit.test=*DeploymentController*IT,DeploymentControllerAuditIT` +Expected: existing `DeploymentControllerIT` still passes (signature changes covered by Task 3 hardcoded values; this task wires the real value). + +- [ ] **Step 6: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java \ + cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerAuditIT.java +git commit -m "feat(audit): audit deploy/stop/promote with DEPLOYMENT category" +``` + +--- + +### Task 5: `LogQueryController` accepts `instanceIds` filter + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/search/LogSearchRequest.java` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LogQueryController.java` +- Modify: the log search service implementation (find via `Grep "LogSearchRequest" --include="*.java"` — likely `ClickHouseLogStore.java` or similar) +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/controller/LogQueryControllerInstanceIdsIT.java` + +- [ ] **Step 1: Write the failing IT** + +```java +package com.cameleer.server.app.controller; + +import com.cameleer.server.app.AbstractPostgresIT; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.http.HttpHeaders; + +import java.time.Instant; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class LogQueryControllerInstanceIdsIT extends AbstractPostgresIT { + + @Autowired TestRestTemplate rest; + // Inject ClickHouse JdbcTemplate (named bean — verify name in StorageBeanConfig) + // For example: @Autowired @Qualifier("clickhouseJdbcTemplate") JdbcTemplate ch; + + private String viewerJwt; + + @BeforeEach + void seedLogs() { + // Insert 3 log rows: instance_id = "default-app1-0-aaa11111", + // "default-app1-1-aaa11111", "default-app1-0-bbb22222" + // All for application=app1, environment=default + // Mint a VIEWER JWT. + } + + @Test + void instanceIds_filter_narrows_results() { + HttpHeaders h = new HttpHeaders(); + h.setBearerAuth(viewerJwt); + + var resp = rest.exchange( + "/api/v1/environments/default/logs?application=app1" + + "&instanceIds=default-app1-0-aaa11111,default-app1-1-aaa11111&limit=50", + org.springframework.http.HttpMethod.GET, + new org.springframework.http.HttpEntity<>(h), Map.class); + + assertThat(resp.getStatusCode().is2xxSuccessful()).isTrue(); + var data = (java.util.List>) resp.getBody().get("data"); + assertThat(data).hasSize(2); + assertThat(data).extracting("instanceId") + .containsExactlyInAnyOrder("default-app1-0-aaa11111", + "default-app1-1-aaa11111"); + } + + @Test + void no_instanceIds_param_returns_all_matching_app_env() { + HttpHeaders h = new HttpHeaders(); + h.setBearerAuth(viewerJwt); + + var resp = rest.exchange( + "/api/v1/environments/default/logs?application=app1&limit=50", + org.springframework.http.HttpMethod.GET, + new org.springframework.http.HttpEntity<>(h), Map.class); + var data = (java.util.List>) resp.getBody().get("data"); + assertThat(data).hasSize(3); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -pl cameleer-server-app verify -Dit.test=LogQueryControllerInstanceIdsIT` +Expected: FAIL — `instanceIds` param is ignored, returns 3 rows in the first test. + +- [ ] **Step 3: Add `instanceIds` to `LogSearchRequest`** + +Edit `LogSearchRequest.java`. Add `List instanceIds` to the record (preserve existing field order; append new field). Update the compact constructor to null-normalize: `instanceIds = instanceIds == null ? List.of() : List.copyOf(instanceIds);` + +- [ ] **Step 4: Add the param to `LogQueryController`** + +In `LogQueryController.java`, add `@RequestParam(required = false) String instanceIds` to the GET handler. In the handler body, split it by comma into a `List` (filter blanks) and pass to the request: + +```java +List instanceIdList = instanceIds == null || instanceIds.isBlank() + ? List.of() + : Arrays.stream(instanceIds.split(",")) + .map(String::trim).filter(s -> !s.isBlank()).toList(); +LogSearchRequest req = new LogSearchRequest(/* existing args */, instanceIdList); +``` + +- [ ] **Step 5: Wire the filter into the ClickHouse query** + +In the log query implementation (typically `ClickHouseLogStore.search(...)` or similar — confirm via `Grep "FROM logs" --include="*.java"`), add when `req.instanceIds()` is non-empty: + +```java +if (!req.instanceIds().isEmpty()) { + String placeholders = String.join(",", Collections.nCopies(req.instanceIds().size(), "?")); + sql.append(" AND instance_id IN (").append(placeholders).append(")"); + params.addAll(req.instanceIds()); +} +``` + +(Match the existing parameter-binding style used by the rest of the file — likely positional `?` with a `params` list.) + +- [ ] **Step 6: Run test to verify it passes** + +Run: `mvn -pl cameleer-server-app verify -Dit.test=LogQueryControllerInstanceIdsIT` +Expected: PASS. + +- [ ] **Step 7: Run all log-related ITs to catch regressions** + +Run: `mvn -pl cameleer-server-app verify -Dit.test=*Log*IT` +Expected: all PASS. + +- [ ] **Step 8: Commit** + +```bash +git add cameleer-server-core/src/main/java/com/cameleer/server/core/search/LogSearchRequest.java \ + cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LogQueryController.java \ + cameleer-server-app/src/main/java/com/cameleer/server/app/search/ClickHouseLogStore.java \ + cameleer-server-app/src/test/java/com/cameleer/server/app/controller/LogQueryControllerInstanceIdsIT.java +git commit -m "feat(logs): add instanceIds multi-value filter to /logs endpoint" +``` + +--- + +### Task 6: Regenerate OpenAPI; surface `createdBy` and `instanceIds` in UI types + +**Files:** +- Regenerate: `ui/src/api/openapi.json` and `ui/src/api/schema.d.ts` +- Modify: `ui/src/api/queries/admin/apps.ts` (`Deployment` interface) +- Modify: `ui/src/api/queries/logs.ts` (`useInfiniteApplicationLogs` accepts `instanceIds`) + +- [ ] **Step 1: Start backend locally** + +In a separate terminal: + +```bash +java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar +``` + +Wait until port 8081 is responsive. + +- [ ] **Step 2: Regenerate from live backend** + +```bash +cd ui && npm run generate-api:live +``` + +This rewrites `src/api/openapi.json` and `src/api/schema.d.ts`. + +- [ ] **Step 3: Update `Deployment` interface** + +In `ui/src/api/queries/admin/apps.ts`, add to the `Deployment` interface: + +```ts +export interface Deployment { + // ... existing fields ... + createdBy: string | null; +} +``` + +- [ ] **Step 4: Update `useInfiniteApplicationLogs`** + +In `ui/src/api/queries/logs.ts`, add an optional `instanceIds?: string[]` parameter to the hook. When non-empty, append `&instanceIds=${instanceIds.join(',')}` to the query string. Add `instanceIds` to the TanStack Query key so different instance lists don't collide in cache. + +- [ ] **Step 5: Type-check the SPA** + +```bash +cd ui && npm run typecheck +``` + +Expected: zero errors. (If `Deployment.createdBy` references in existing code break, fix at the call site — most readers will tolerate `null`.) + +- [ ] **Step 6: Commit** + +```bash +git add ui/src/api/openapi.json ui/src/api/schema.d.ts \ + ui/src/api/queries/admin/apps.ts \ + ui/src/api/queries/logs.ts +git commit -m "chore(api): regenerate types — Deployment.createdBy + logs instanceIds" +``` + +--- + +## Phase 2 — UI primitive: SideDrawer + +### Task 7: Build `SideDrawer` component with tests + +**Files:** +- Create: `ui/src/components/SideDrawer.tsx` +- Create: `ui/src/components/SideDrawer.module.css` +- Create: `ui/src/components/SideDrawer.test.tsx` + +- [ ] **Step 1: Write the failing tests** + +```tsx +// ui/src/components/SideDrawer.test.tsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SideDrawer } from './SideDrawer'; + +describe('SideDrawer', () => { + it('renders nothing when closed', () => { + render( {}} title="X">body); + expect(screen.queryByText('body')).toBeNull(); + }); + + it('renders title, body, and close button when open', () => { + render( {}} title="My Title">body content); + expect(screen.getByText('My Title')).toBeInTheDocument(); + expect(screen.getByText('body content')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument(); + }); + + it('calls onClose when close button clicked', () => { + const onClose = vi.fn(); + render(body); + fireEvent.click(screen.getByRole('button', { name: /close/i })); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('calls onClose when ESC pressed', () => { + const onClose = vi.fn(); + render(body); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('calls onClose when backdrop clicked', () => { + const onClose = vi.fn(); + render(body); + fireEvent.click(screen.getByTestId('side-drawer-backdrop')); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('renders footer when provided', () => { + render( + {}} title="X" footer={}> + body + + ); + expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd ui && npx vitest run src/components/SideDrawer.test.tsx +``` + +Expected: all 6 tests fail (module not found). + +- [ ] **Step 3: Implement the component** + +`ui/src/components/SideDrawer.tsx`: + +```tsx +import { useEffect, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import styles from './SideDrawer.module.css'; + +interface SideDrawerProps { + open: boolean; + onClose: () => void; + title: ReactNode; + size?: 'md' | 'lg' | 'xl'; + footer?: ReactNode; + children: ReactNode; +} + +export function SideDrawer({ + open, onClose, title, size = 'lg', footer, children, +}: SideDrawerProps) { + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [open, onClose]); + + if (!open) return null; + + return createPortal( +
+
+ +
, + document.body + ); +} +``` + +- [ ] **Step 4: Implement the CSS** + +`ui/src/components/SideDrawer.module.css`: + +```css +.root { + position: fixed; + inset: 0; + z-index: 950; + pointer-events: none; +} +.backdrop { + position: absolute; + inset: 0; + pointer-events: auto; + /* transparent — no dim */ +} +.drawer { + position: absolute; + top: 0; + right: 0; + bottom: 0; + background: var(--bg); + border-left: 1px solid var(--border); + box-shadow: -8px 0 24px var(--shadow-lg, rgba(0, 0, 0, 0.25)); + display: flex; + flex-direction: column; + pointer-events: auto; + animation: slideIn 240ms ease-out; +} +.size-md { width: 560px; max-width: 100vw; } +.size-lg { width: 720px; max-width: 100vw; } +.size-xl { width: 900px; max-width: 100vw; } +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.title { flex: 1; font-weight: 600; } +.closeBtn { + background: transparent; + border: 0; + font-size: 24px; + line-height: 1; + color: var(--text-muted); + cursor: pointer; + padding: 0 4px; +} +.closeBtn:hover { color: var(--text); } +.body { flex: 1; overflow-y: auto; padding: 16px 18px; } +.footer { + flex-shrink: 0; + padding: 14px 18px; + border-top: 1px solid var(--border); + background: var(--bg-subtle); +} +@keyframes slideIn { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +cd ui && npx vitest run src/components/SideDrawer.test.tsx +``` + +Expected: all 6 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add ui/src/components/SideDrawer.tsx \ + ui/src/components/SideDrawer.module.css \ + ui/src/components/SideDrawer.test.tsx +git commit -m "feat(ui): add SideDrawer component (project-local)" +``` + +--- + +## Phase 3 — UI: ConfigTabs `readOnly?` props + +### Task 8: Add `readOnly?` prop to all five ConfigTabs + +Each of the five tabs gets the same minimal change. Group them in one task — the change is mechanical. + +**Files (all under `ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/`):** +- Modify: `MonitoringTab.tsx` +- Modify: `ResourcesTab.tsx` +- Modify: `VariablesTab.tsx` +- Modify: `SensitiveKeysTab.tsx` +- Modify: `LiveBanner.tsx` (no-op when read-only — accept and check a prop) +- Modify: the Deployment ConfigTab (verify exact filename via `Glob`) +- Test: One smoke test per tab to assert read-only behavior + +- [ ] **Step 1: Read each tab to understand the current shape** + +For each tab, identify: +- The form-edit inputs +- The Save / Apply / submit buttons +- Any `LiveBanner` / "live mode" affordances + +Add `readOnly?: boolean` to the props interface. When true: +- Pass `disabled` to all inputs (``, ` + setReplicaFilter(e.target.value === 'all' ? 'all' : Number(e.target.value)) + } + > + + {deployment.replicaStates.map((r) => ( + + ))} + +
+ + + ); +} +``` + +- [ ] **Step 5: Implement `index.tsx` (drawer container)** + +```tsx +import { useState } from 'react'; +import { SideDrawer } from '../../../../components/SideDrawer'; +import { Tabs, Button, Badge } from '@cameleer/design-system'; +import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps'; +import { LogsPanel } from './LogsPanel'; +import { ConfigPanel } from './ConfigPanel'; + +interface Props { + open: boolean; + onClose: () => void; + deployment: Deployment; + version?: AppVersion; + appSlug: string; + envSlug: string; + onRestore: (deploymentId: string) => void; +} + +export function CheckpointDetailDrawer({ + open, onClose, deployment, version, appSlug, envSlug, onRestore, +}: Props) { + const [tab, setTab] = useState<'logs' | 'config'>('logs'); + const archived = !version; + + const title = ( +
+ {' '} + + {version?.jarFilename ?? 'JAR pruned'} + {' '} + {deployment.status} +
+ ); + + const footer = ( +
+ + Restoring hydrates the form — you'll still need to Redeploy. + + +
+ ); + + return ( + +
+ Deployed by {deployment.createdBy ?? '—'} ·{' '} + {deployment.deployedAt} · Strategy: {deployment.deploymentStrategy} ·{' '} + {deployment.replicaStates.length} replicas +
+ setTab(t as 'logs' | 'config')} + tabs={[ + { id: 'logs', label: 'Logs' }, + { id: 'config', label: 'Config' }, + ]} + /> + {tab === 'logs' && ( + + )} + {tab === 'config' && ( + + )} +
+ ); +} +``` + +(Stub `ConfigPanel` for now to satisfy import — will be implemented in Task 11.) + +```tsx +// ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.tsx +import type { Deployment } from '../../../../api/queries/admin/apps'; +interface Props { + deployment: Deployment; + appSlug: string; + envSlug: string; + archived: boolean; +} +export function ConfigPanel(_: Props) { + return
Config panel — implemented in next task
; +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +```bash +cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer +``` + +Expected: all PASS. + +- [ ] **Step 7: Type-check** + +```bash +cd ui && npm run typecheck +``` + +Expected: zero errors. + +- [ ] **Step 8: Commit** + +```bash +git add ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ +git commit -m "feat(ui): CheckpointDetailDrawer container + LogsPanel" +``` + +--- + +### Task 11: Implement `ConfigPanel` with Snapshot/Diff modes + +**Files:** +- Modify: `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.tsx` +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/diff.ts` (deep-equal helper) +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/diff.test.ts` +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.test.tsx` + +- [ ] **Step 1: Write the failing diff helper test** + +```ts +// diff.test.ts +import { describe, it, expect } from 'vitest'; +import { fieldDiff } from './diff'; + +describe('fieldDiff', () => { + it('returns empty list for equal objects', () => { + expect(fieldDiff({ a: 1, b: 'x' }, { a: 1, b: 'x' })).toEqual([]); + }); + + it('detects changed values', () => { + expect(fieldDiff({ a: 1 }, { a: 2 })).toEqual([{ path: 'a', oldValue: 1, newValue: 2 }]); + }); + + it('detects added keys', () => { + expect(fieldDiff({}, { a: 1 })).toEqual([{ path: 'a', oldValue: undefined, newValue: 1 }]); + }); + + it('detects removed keys', () => { + expect(fieldDiff({ a: 1 }, {})).toEqual([{ path: 'a', oldValue: 1, newValue: undefined }]); + }); + + it('walks nested objects', () => { + const diff = fieldDiff({ resources: { mem: 512 } }, { resources: { mem: 1024 } }); + expect(diff).toEqual([{ path: 'resources.mem', oldValue: 512, newValue: 1024 }]); + }); + + it('compares arrays by position', () => { + const diff = fieldDiff({ keys: ['a', 'b'] }, { keys: ['a', 'c'] }); + expect(diff).toEqual([{ path: 'keys[1]', oldValue: 'b', newValue: 'c' }]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/diff.test.ts +``` + +Expected: all 6 tests fail. + +- [ ] **Step 3: Implement `diff.ts`** + +```ts +export interface FieldDiff { + path: string; + oldValue: unknown; + newValue: unknown; +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +export function fieldDiff( + a: unknown, b: unknown, path = '' +): FieldDiff[] { + if (Object.is(a, b)) return []; + + if (isPlainObject(a) && isPlainObject(b)) { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + const out: FieldDiff[] = []; + for (const k of keys) { + const sub = path ? `${path}.${k}` : k; + out.push(...fieldDiff(a[k], b[k], sub)); + } + return out; + } + + if (Array.isArray(a) && Array.isArray(b)) { + const len = Math.max(a.length, b.length); + const out: FieldDiff[] = []; + for (let i = 0; i < len; i++) { + out.push(...fieldDiff(a[i], b[i], `${path}[${i}]`)); + } + return out; + } + + return [{ path: path || '(root)', oldValue: a, newValue: b }]; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/diff.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Write `ConfigPanel.test.tsx`** + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ConfigPanel } from './ConfigPanel'; + +const dep: any = { + deployedConfigSnapshot: { + jarVersionId: 'v6', agentConfig: null, + containerConfig: { resources: { memoryLimitMb: 512 } }, + sensitiveKeys: null, + }, +}; + +function wrap(node: React.ReactNode) { + const qc = new QueryClient(); + return {node}; +} + +describe('ConfigPanel', () => { + it('renders the 5 read-only sub-tabs in Snapshot mode', () => { + render(wrap()); + ['Resources', 'Monitoring', 'Variables', 'Sensitive Keys', 'Deployment'].forEach(t => + expect(screen.getByRole('tab', { name: new RegExp(t, 'i') })).toBeInTheDocument() + ); + }); + + it('hides the Snapshot/Diff toggle when archived', () => { + render(wrap()); + expect(screen.queryByRole('button', { name: /diff/i })).toBeNull(); + }); + + it('shows the toggle when not archived', () => { + render(wrap()); + expect(screen.getByText(/snapshot/i)).toBeInTheDocument(); + expect(screen.getByText(/diff vs current/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 6: Run test to verify it fails** + +```bash +cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.test.tsx +``` + +Expected: tests fail (stub still in place). + +- [ ] **Step 7: Implement `ConfigPanel.tsx`** + +```tsx +import { useMemo, useState } from 'react'; +import { SegmentedTabs, Tabs } from '@cameleer/design-system'; +import { useApplicationConfig } from '../../../../api/queries/admin/applicationConfig'; +// (verify the exact hook name — adjust to whatever the live form uses) +import type { Deployment } from '../../../../api/queries/admin/apps'; +import { fieldDiff, type FieldDiff } from './diff'; +import { MonitoringTab } from '../ConfigTabs/MonitoringTab'; +import { ResourcesTab } from '../ConfigTabs/ResourcesTab'; +import { VariablesTab } from '../ConfigTabs/VariablesTab'; +import { SensitiveKeysTab } from '../ConfigTabs/SensitiveKeysTab'; +import { DeploymentTabConfig } from '../ConfigTabs/DeploymentTab'; // verify name + +interface Props { + deployment: Deployment; + appSlug: string; + envSlug: string; + archived: boolean; +} + +type Mode = 'snapshot' | 'diff'; +type SubTab = 'monitoring' | 'resources' | 'variables' | 'sensitive' | 'deployment'; + +export function ConfigPanel({ deployment, appSlug, envSlug, archived }: Props) { + const [mode, setMode] = useState('snapshot'); + const [subTab, setSubTab] = useState('resources'); + + const snapshot = deployment.deployedConfigSnapshot; + const liveQuery = useApplicationConfig(appSlug, envSlug); + const live = liveQuery.data; + + const diff: FieldDiff[] = useMemo( + () => mode === 'diff' && snapshot && live ? fieldDiff(snapshot, live) : [], + [mode, snapshot, live] + ); + + const countByTab = useMemo(() => { + const map: Record = { + monitoring: 0, resources: 0, variables: 0, sensitive: 0, deployment: 0, + }; + for (const d of diff) { + // Map diff path prefixes to sub-tabs. Prefixes derive from the sub-tab field + // structure — adjust based on how the backend snapshot is shaped. + if (d.path.startsWith('containerConfig.resources') || + d.path.startsWith('containerConfig.memory') || + d.path.startsWith('containerConfig.cpu')) map.resources++; + else if (d.path.startsWith('containerConfig.customEnvVars') || + d.path.startsWith('agentConfig.variables')) map.variables++; + else if (d.path.startsWith('sensitiveKeys')) map.sensitive++; + else if (d.path.startsWith('containerConfig.deploymentStrategy') || + d.path.startsWith('containerConfig.replicas')) map.deployment++; + else map.monitoring++; + } + return map; + }, [diff]); + + return ( +
+ {!archived && ( + setMode(m as Mode)} + /> + )} + + setSubTab(t as SubTab)} + tabs={[ + { id: 'resources', label: `Resources${countByTab.resources ? ` (${countByTab.resources})` : ''}` }, + { id: 'monitoring', label: `Monitoring${countByTab.monitoring ? ` (${countByTab.monitoring})` : ''}` }, + { id: 'variables', label: `Variables${countByTab.variables ? ` (${countByTab.variables})` : ''}` }, + { id: 'sensitive', label: `Sensitive Keys${countByTab.sensitive ? ` (${countByTab.sensitive})` : ''}` }, + { id: 'deployment', label: `Deployment${countByTab.deployment ? ` (${countByTab.deployment})` : ''}` }, + ]} + /> + + {mode === 'snapshot' && ( + <> + {subTab === 'resources' && } + {subTab === 'monitoring' && } + {subTab === 'variables' && } + {subTab === 'sensitive' && } + {subTab === 'deployment' && } + + )} + + {mode === 'diff' && ( + + )} +
+ ); +} + +function diffsForTab(all: FieldDiff[], tab: SubTab): FieldDiff[] { + return all.filter((d) => { + if (tab === 'resources') { + return d.path.startsWith('containerConfig.resources') || + d.path.startsWith('containerConfig.memory') || + d.path.startsWith('containerConfig.cpu'); + } + if (tab === 'variables') { + return d.path.startsWith('containerConfig.customEnvVars') || + d.path.startsWith('agentConfig.variables'); + } + if (tab === 'sensitive') { + return d.path.startsWith('sensitiveKeys'); + } + if (tab === 'deployment') { + return d.path.startsWith('containerConfig.deploymentStrategy') || + d.path.startsWith('containerConfig.replicas'); + } + // monitoring catches everything else + return !(d.path.startsWith('containerConfig.resources') || + d.path.startsWith('containerConfig.memory') || + d.path.startsWith('containerConfig.cpu') || + d.path.startsWith('containerConfig.customEnvVars') || + d.path.startsWith('agentConfig.variables') || + d.path.startsWith('sensitiveKeys') || + d.path.startsWith('containerConfig.deploymentStrategy') || + d.path.startsWith('containerConfig.replicas')); + }); +} + +function DiffView({ diffs }: { diffs: FieldDiff[] }) { + if (diffs.length === 0) { + return
+ No differences in this section. +
; + } + return ( +
+ {diffs.map((d) => ( +
+
{d.path}
+
+ - {JSON.stringify(d.oldValue)} +
+
+ + {JSON.stringify(d.newValue)} +
+
+ ))} +
+ ); +} +``` + +> The exact prop signature each ConfigTab needs to receive depends on how the live form passes them today. Open the existing `ConfigTabs/*.tsx` files to see what props they consume in the live page (form state, change handlers, derived values), and pass the snapshot-derived equivalents. The `readOnly` prop added in Task 8 lets you safely pass mock change handlers / disabled state. + +- [ ] **Step 8: Run tests to verify they pass** + +```bash +cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.test.tsx +``` + +Expected: all PASS. + +- [ ] **Step 9: Commit** + +```bash +git add ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.tsx \ + ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/diff.ts \ + ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/diff.test.ts \ + ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.test.tsx +git commit -m "feat(ui): ConfigPanel with snapshot+diff modes" +``` + +--- + +### Task 12: Wire CheckpointsTable + Drawer into IdentitySection + +**Files:** +- Modify: `ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx` +- Delete: `ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx` + +- [ ] **Step 1: Read `IdentitySection.tsx` to find the existing Checkpoints integration** + +Locate where `` is rendered. Note the props it receives. + +- [ ] **Step 2: Replace the import + rendering** + +```tsx +// At top of IdentitySection.tsx +import { useState } from 'react'; +import { CheckpointsTable } from './CheckpointsTable'; +import { CheckpointDetailDrawer } from './CheckpointDetailDrawer'; +import { useEnvironments } from '../../../api/queries/admin/environments'; + +// In the component body +const [selectedCheckpointId, setSelectedCheckpointId] = useState(null); +const { data: envs } = useEnvironments(); +const env = envs?.find((e) => e.slug === envSlug); +const jarRetentionCount = env?.jarRetentionCount ?? 0; +const versionMap = new Map(versions.map((v) => [v.id, v])); +const selectedDep = deployments.find((d) => d.id === selectedCheckpointId) ?? null; +const selectedVersion = selectedDep ? versionMap.get(selectedDep.appVersionId) : undefined; + +// Where used to be: + +{selectedDep && ( + setSelectedCheckpointId(null)} + deployment={selectedDep} + version={selectedVersion} + appSlug={appSlug} + envSlug={envSlug} + onRestore={onRestore} + /> +)} +``` + +- [ ] **Step 3: Delete the old Checkpoints component** + +```bash +rm ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx +``` + +- [ ] **Step 4: Type-check** + +```bash +cd ui && npm run typecheck +``` + +Expected: zero errors. (If the env hook name differs, adjust the import path.) + +- [ ] **Step 5: Run all UI tests** + +```bash +cd ui && npm run test +``` + +Expected: all PASS. + +- [ ] **Step 6: Manual smoke test** + +Start backend + UI dev: + +```bash +# terminal 1: backend +java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar +# terminal 2: UI +cd ui && npm run dev +``` + +Browse to the deployment page for an app with at least 2 historical deployments. Verify: +- Table renders with all expected columns +- Row click opens the drawer +- Logs tab loads logs for the deployment's instance_ids +- Config tab renders read-only sub-tabs +- Diff toggle (when JAR available) shows differences vs current live config +- Restore button hydrates the form (existing behavior) +- Pruned-JAR row is dimmed; Restore disabled in drawer; Diff toggle hidden + +- [ ] **Step 7: Commit** + +```bash +git add ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx +git rm ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx +git commit -m "feat(ui): wire CheckpointsTable + Drawer into IdentitySection (delete old Checkpoints)" +``` + +--- + +## Phase 5 — Polish + +### Task 13: Surface `DEPLOYMENT` category in Admin Audit page filter + +**Files:** +- Modify: `ui/src/pages/Admin/AuditLogPage.tsx` (verify path; the rules note it exists) + +- [ ] **Step 1: Locate the category filter dropdown** + +Open the file. Find the ``) bound to the category filter. It will have an array of options like `['INFRA', 'AUTH', 'USER_MGMT', ...]`. + +- [ ] **Step 2: Add `DEPLOYMENT` option** + +Append `{ value: 'DEPLOYMENT', label: 'Deployment' }` (or matching shape) to the options array. + +- [ ] **Step 3: Manual smoke test** + +Browse to Admin → Audit page. Filter by Deployment. Confirm rows appear after running a deploy. + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/pages/Admin/AuditLogPage.tsx +git commit -m "ui(audit): surface DEPLOYMENT category in admin filter" +``` + +--- + +### Task 14: Update `.claude/rules/` documentation + +**Files:** +- Modify: `.claude/rules/app-classes.md` (DeploymentController audit calls + LogQueryController instanceIds param) +- Modify: `.claude/rules/ui.md` (CheckpointsTable + SideDrawer pattern) +- Modify: `.claude/rules/core-classes.md` (`AuditCategory.DEPLOYMENT`, `Deployment.createdBy`) + +- [ ] **Step 1: Update `app-classes.md`** + +In the `DeploymentController` bullet, replace the existing description with one that mentions audit: + +```md +- `DeploymentController` — `/api/v1/environments/{envSlug}/apps/{appSlug}/deployments`. GET list / POST create (body `{ appVersionId }`, audited as `deploy_app`) / POST `{id}/stop` (audited as `stop_deployment`) / POST `{id}/promote` (audited as `promote_deployment`) / GET `{id}/logs`. All lifecycle ops audited under `AuditCategory.DEPLOYMENT` with the acting user resolved via the `user:` strip convention; FAILURE branches also write audit rows. +``` + +In the `LogQueryController` bullet, add to the filter list: + +```md +- `LogQueryController` — GET `/api/v1/environments/{envSlug}/logs` (filters: source (multi, comma-split), level (multi, comma-split), application, agentId, exchangeId, logger, q, instanceIds (multi, comma-split — `WHERE instance_id IN (...)` for per-deployment scoping), time range; sort asc/desc). ... +``` + +- [ ] **Step 2: Update `core-classes.md`** + +In the `AuditCategory` bullet, add `DEPLOYMENT` to the enum list. In the `Deployment` record bullet, append `, createdBy (String user_id)`. + +- [ ] **Step 3: Update `ui.md`** + +Under "Deployments" / `AppDeploymentPage`, add or update the Checkpoints description: + +```md +- Checkpoints render as a `CheckpointsTable` (DataTable-style) with columns: Version · JAR · Deployed by · Deployed · Strategy · Outcome · ›. Row click opens `CheckpointDetailDrawer` (project-local `SideDrawer` primitive — `ui/src/components/SideDrawer.tsx`). Drawer has Logs and Config tabs; Config has Snapshot / Diff vs current modes. Restore lives in the drawer footer (forces review). Visible row cap = `Environment.jarRetentionCount` (default 10 if 0); older rows accessible via "Show older (N)" expander. +``` + +Under shared components, add: + +```md +- `ui/src/components/SideDrawer.tsx` — project-local right-slide drawer (DS has Modal but no Drawer). Portal-based, ESC + transparent-backdrop click closes. `size: 'md' | 'lg' | 'xl'`. Used by CheckpointDetailDrawer; promote to DS if a second consumer appears. +``` + +- [ ] **Step 4: Commit** + +```bash +git add .claude/rules/app-classes.md .claude/rules/ui.md .claude/rules/core-classes.md +git commit -m "docs(rules): deployment audit + checkpoints table + SideDrawer" +``` + +--- + +### Task 15: Final integration smoke test + +- [ ] **Step 1: Full backend build with all ITs** + +```bash +mvn clean verify +``` + +Expected: BUILD SUCCESS with all unit tests + ITs green. + +- [ ] **Step 2: Full UI build + tests** + +```bash +cd ui && npm run typecheck && npm run test && npm run build +``` + +Expected: zero errors. + +- [ ] **Step 3: End-to-end manual verification** + +Start backend + UI. As an OPERATOR user "alice": + +1. Deploy app v1 → expect audit row `deploy_app` SUCCESS with actor=alice, category=DEPLOYMENT +2. Deploy app v2 → previous deployment becomes a checkpoint +3. Open the Checkpoints table → expect v1 row with deployer=alice +4. Click the v1 row → drawer opens +5. Logs tab → see logs for v1's instance_ids only +6. Config tab → snapshot mode shows v1 config; toggle Diff → see differences vs current v2 +7. Stop v2 → audit row `stop_deployment` SUCCESS +8. Restore v1 from drawer → form hydrates; click Redeploy → audit row `deploy_app` SUCCESS +9. Browse to Admin → Audit → filter Category = DEPLOYMENT → see all four entries + +- [ ] **Step 4: Commit (no code changes — the smoke test is verification)** + +If anything failed during smoke test, fix in a focused task, then re-run this checklist. Otherwise nothing to commit at this step. + +--- + +## Self-review checklist (run after writing the plan) + +This was performed during plan authoring. Spot any gaps before kicking off implementation. + +- ✅ V2 migration covered (Task 1) +- ✅ AuditCategory.DEPLOYMENT covered (Task 2) +- ✅ Deployment record + service signature covered (Task 3) +- ✅ DeploymentController audit calls covered (Task 4) +- ✅ LogQueryController instanceIds covered (Task 5) +- ✅ OpenAPI regeneration + UI Deployment.createdBy + useInfiniteApplicationLogs covered (Task 6) +- ✅ SideDrawer covered (Task 7) +- ✅ ConfigTabs readOnly covered (Task 8) +- ✅ CheckpointsTable covered (Task 9) +- ✅ CheckpointDetailDrawer + LogsPanel covered (Task 10) +- ✅ ConfigPanel snapshot+diff covered (Task 11) +- ✅ IdentitySection wiring + delete old Checkpoints covered (Task 12) +- ✅ Admin Audit dropdown covered (Task 13) +- ✅ Rules docs covered (Task 14) +- ✅ Smoke test covered (Task 15) + +**Type/signature consistency:** `Deployment.createdBy: String | null` consistent across record (Task 3), DTO (Task 6), and UI consumers (Tasks 9-12). `AuditCategory.DEPLOYMENT` consistent everywhere. `instanceIds` named identically in `LogSearchRequest`, query param, and `useInfiniteApplicationLogs` signature. + +**Pre-flight requirement before starting:** confirm `~/.testcontainers.properties` has `testcontainers.reuse.enable=true` (set during the previous build-perf work) so IT runs are fast.