# 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/V4__add_deployment_created_by.sql` - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/storage/V4DeploymentCreatedByMigrationIT.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 V4DeploymentCreatedByMigrationIT 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=V4DeploymentCreatedByMigrationIT` Expected: tests FAIL — column does not exist. - [ ] **Step 3: Create the migration file** `cameleer-server-app/src/main/resources/db/migration/V4__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=V4DeploymentCreatedByMigrationIT` Expected: all 3 tests PASS. - [ ] **Step 5: Commit** ```bash git add cameleer-server-app/src/main/resources/db/migration/V4__add_deployment_created_by.sql \ cameleer-server-app/src/test/java/com/cameleer/server/app/storage/V4DeploymentCreatedByMigrationIT.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.