78 KiB
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
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<Map<String, Object>> 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:
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
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):
@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:
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
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 withGrep "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
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
createdByto theDeploymentrecord
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):
public record Deployment(
UUID id,
UUID appId,
UUID appVersionId,
UUID environmentId,
DeploymentStatus status,
String targetState,
String deploymentStrategy,
DeployStage deployStage,
List<ReplicaState> replicaStates,
Map<String, Object> 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
DeploymentServiceinterface
Edit DeploymentService.java. Change the two methods:
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
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
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<Map<String, Object>> 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+createdByintoDeploymentController
Edit DeploymentController.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(...):
@PostMapping
public ResponseEntity<Deployment> 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(...):
@PostMapping("/{deploymentId}/stop")
public ResponseEntity<Deployment> 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(...):
@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 inOutboundConnectionAdminController(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
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"— likelyClickHouseLogStore.javaor similar) -
Test:
cameleer-server-app/src/test/java/com/cameleer/server/app/controller/LogQueryControllerInstanceIdsIT.java -
Step 1: Write the failing IT
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<Map<String,Object>>) 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<Map<String,Object>>) 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
instanceIdstoLogSearchRequest
Edit LogSearchRequest.java. Add List<String> 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<String> (filter blanks) and pass to the request:
List<String> 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:
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
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.jsonandui/src/api/schema.d.ts -
Modify:
ui/src/api/queries/admin/apps.ts(Deploymentinterface) -
Modify:
ui/src/api/queries/logs.ts(useInfiniteApplicationLogsacceptsinstanceIds) -
Step 1: Start backend locally
In a separate terminal:
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
cd ui && npm run generate-api:live
This rewrites src/api/openapi.json and src/api/schema.d.ts.
- Step 3: Update
Deploymentinterface
In ui/src/api/queries/admin/apps.ts, add to the Deployment interface:
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
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
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
// 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(<SideDrawer open={false} onClose={() => {}} title="X">body</SideDrawer>);
expect(screen.queryByText('body')).toBeNull();
});
it('renders title, body, and close button when open', () => {
render(<SideDrawer open onClose={() => {}} title="My Title">body content</SideDrawer>);
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(<SideDrawer open onClose={onClose} title="X">body</SideDrawer>);
fireEvent.click(screen.getByRole('button', { name: /close/i }));
expect(onClose).toHaveBeenCalledOnce();
});
it('calls onClose when ESC pressed', () => {
const onClose = vi.fn();
render(<SideDrawer open onClose={onClose} title="X">body</SideDrawer>);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledOnce();
});
it('calls onClose when backdrop clicked', () => {
const onClose = vi.fn();
render(<SideDrawer open onClose={onClose} title="X">body</SideDrawer>);
fireEvent.click(screen.getByTestId('side-drawer-backdrop'));
expect(onClose).toHaveBeenCalledOnce();
});
it('renders footer when provided', () => {
render(
<SideDrawer open onClose={() => {}} title="X" footer={<button>Save</button>}>
body
</SideDrawer>
);
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
});
});
- Step 2: Run tests to verify they fail
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:
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(
<div className={styles.root}>
<div
className={styles.backdrop}
data-testid="side-drawer-backdrop"
onClick={onClose}
/>
<aside className={`${styles.drawer} ${styles[`size-${size}`]}`} role="dialog" aria-modal="true">
<header className={styles.header}>
<div className={styles.title}>{title}</div>
<button
type="button"
aria-label="Close drawer"
className={styles.closeBtn}
onClick={onClose}
>×</button>
</header>
<div className={styles.body}>{children}</div>
{footer && <footer className={styles.footer}>{footer}</footer>}
</aside>
</div>,
document.body
);
}
- Step 4: Implement the CSS
ui/src/components/SideDrawer.module.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
cd ui && npx vitest run src/components/SideDrawer.test.tsx
Expected: all 6 tests PASS.
- Step 6: Commit
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
disabledto all inputs (<input disabled>,<Select disabled>, etc.) -
Hide save/apply buttons via
{!readOnly && <Button>...</Button>} -
Hide
<LiveBanner>via{!readOnly && <LiveBanner ... />} -
Step 2: Write per-tab smoke tests
For one representative tab (e.g. ResourcesTab.tsx), create ResourcesTab.test.tsx:
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ResourcesTab } from './ResourcesTab';
describe('ResourcesTab readOnly', () => {
const baseProps = { /* fill with the minimum props the tab needs to render */ };
it('renders inputs disabled when readOnly', () => {
render(<ResourcesTab {...baseProps} readOnly />);
screen.getAllByRole('textbox').forEach(el => {
expect(el).toBeDisabled();
});
});
it('hides Save button when readOnly', () => {
render(<ResourcesTab {...baseProps} readOnly />);
expect(screen.queryByRole('button', { name: /save/i })).toBeNull();
});
it('shows Save button when not readOnly', () => {
render(<ResourcesTab {...baseProps} />);
expect(screen.queryByRole('button', { name: /save/i })).toBeInTheDocument();
});
});
Repeat for each of the other four tabs (small, mostly identical tests).
- Step 3: Run tests to verify they fail
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/ConfigTabs
Expected: tests fail — readOnly prop ignored.
- Step 4: Apply the prop in each tab
Per tab: add readOnly to props, pass disabled={readOnly} to each input/select, gate save buttons + LiveBanner with !readOnly.
If a tab has derived state that mixes with form state in a way that readOnly doesn't cleanly partition, refactor that tab now — don't ship leaky read-only behavior. Move derived state into a memoized selector or split the tab into a View and Edit component.
- Step 5: Run tests to verify they pass
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/ConfigTabs
Expected: all PASS.
- Step 6: Type-check
cd ui && npm run typecheck
Expected: zero errors.
- Step 7: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/
git commit -m "feat(ui): readOnly prop on the 5 config tabs"
Phase 4 — UI: CheckpointsTable + CheckpointDetailDrawer
Task 9: Build CheckpointsTable
Files:
-
Create:
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx -
Create:
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx -
Modify:
ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css(only if new styles needed) -
Step 1: Write the failing tests
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { CheckpointsTable } from './CheckpointsTable';
import type { Deployment, AppVersion } from '../../../api/queries/admin/apps';
const v6: AppVersion = { id: 'v6id', appId: 'a', version: 6, jarPath: '/j',
jarChecksum: 'c', jarFilename: 'my-app-1.2.3.jar', jarSizeBytes: 1, detectedRuntimeType: null,
detectedMainClass: null, uploadedAt: '2026-04-23T10:00:00Z' };
const stoppedDep: Deployment = {
id: 'd1', appId: 'a', appVersionId: 'v6id', environmentId: 'e',
status: 'STOPPED', targetState: 'STOPPED', deploymentStrategy: 'BLUE_GREEN',
replicaStates: [{ index: 0, containerId: 'c', containerName: 'n', status: 'STOPPED' }],
deployStage: null, containerId: null, containerName: null, errorMessage: null,
deployedAt: '2026-04-23T10:35:00Z', stoppedAt: '2026-04-23T10:52:00Z',
createdAt: '2026-04-23T10:35:00Z', createdBy: 'alice',
deployedConfigSnapshot: { jarVersionId: 'v6id', agentConfig: null, containerConfig: {}, sensitiveKeys: null },
};
describe('CheckpointsTable', () => {
it('renders a row per checkpoint with version, jar, deployer', () => {
render(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
expect(screen.getByText('v6')).toBeInTheDocument();
expect(screen.getByText('my-app-1.2.3.jar')).toBeInTheDocument();
expect(screen.getByText('alice')).toBeInTheDocument();
});
it('row click invokes onSelect with deploymentId', () => {
const onSelect = vi.fn();
render(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={5} onSelect={onSelect} />);
fireEvent.click(screen.getByRole('row', { name: /v6/i }));
expect(onSelect).toHaveBeenCalledWith('d1');
});
it('renders em-dash for null createdBy', () => {
const noActor = { ...stoppedDep, createdBy: null };
render(<CheckpointsTable deployments={[noActor]} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
expect(screen.getByText('—')).toBeInTheDocument();
});
it('marks pruned-JAR rows as archived', () => {
const pruned = { ...stoppedDep, appVersionId: 'unknown' };
render(<CheckpointsTable deployments={[pruned]} versions={[]}
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
expect(screen.getByText(/archived/i)).toBeInTheDocument();
});
it('excludes the currently-running deployment', () => {
render(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
currentDeploymentId="d1" jarRetentionCount={5} onSelect={() => {}} />);
expect(screen.queryByText('v6')).toBeNull();
expect(screen.getByText(/no past deployments/i)).toBeInTheDocument();
});
it('caps visible rows at jarRetentionCount and shows expander', () => {
const many = Array.from({ length: 10 }, (_, i) => ({
...stoppedDep, id: `d${i}`, deployedAt: `2026-04-23T10:${10 + i}:00Z`,
}));
render(<CheckpointsTable deployments={many} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={3} onSelect={() => {}} />);
expect(screen.getAllByRole('row').length).toBeLessThanOrEqual(3 + 2);
expect(screen.getByText(/show older \(7\)/i)).toBeInTheDocument();
});
it('shows all rows when jarRetentionCount >= total', () => {
render(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={10} onSelect={() => {}} />);
expect(screen.queryByText(/show older/i)).toBeNull();
});
it('falls back to default cap of 10 when jarRetentionCount is 0', () => {
const fifteen = Array.from({ length: 15 }, (_, i) => ({
...stoppedDep, id: `d${i}`, deployedAt: `2026-04-23T10:${10 + i}:00Z`,
}));
render(<CheckpointsTable deployments={fifteen} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={0} onSelect={() => {}} />);
expect(screen.getByText(/show older \(5\)/i)).toBeInTheDocument();
});
});
- Step 2: Run tests to verify they fail
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx
Expected: all 8 tests fail (module not found).
- Step 3: Implement the component
CheckpointsTable.tsx:
import { useState } from 'react';
import { Badge } from '@cameleer/design-system';
import type { Deployment, AppVersion } from '../../../api/queries/admin/apps';
import { timeAgo } from '../../../utils/format-utils';
import styles from './AppDeploymentPage.module.css';
interface CheckpointsTableProps {
deployments: Deployment[];
versions: AppVersion[];
currentDeploymentId: string | null;
jarRetentionCount: number;
onSelect: (deploymentId: string) => void;
}
const FALLBACK_CAP = 10;
export function CheckpointsTable({
deployments, versions, currentDeploymentId, jarRetentionCount, onSelect,
}: CheckpointsTableProps) {
const [expanded, setExpanded] = useState(false);
const versionMap = new Map(versions.map((v) => [v.id, v]));
const checkpoints = deployments
.filter((d) => d.deployedConfigSnapshot && d.id !== currentDeploymentId)
.sort((a, b) => (b.deployedAt ?? '').localeCompare(a.deployedAt ?? ''));
if (checkpoints.length === 0) {
return <div className={styles.checkpointEmpty}>No past deployments yet.</div>;
}
const cap = jarRetentionCount > 0 ? jarRetentionCount : FALLBACK_CAP;
const visible = expanded ? checkpoints : checkpoints.slice(0, cap);
const hidden = checkpoints.length - visible.length;
return (
<div className={styles.checkpointsTable}>
<table>
<thead>
<tr>
<th>Version</th>
<th>JAR</th>
<th>Deployed by</th>
<th>Deployed</th>
<th>Strategy</th>
<th>Outcome</th>
<th aria-label="expand"></th>
</tr>
</thead>
<tbody>
{visible.map((d) => {
const v = versionMap.get(d.appVersionId);
const archived = !v;
return (
<tr
key={d.id}
role="row"
aria-label={`Deployment v${v?.version ?? '?'} ${v?.jarFilename ?? ''}`}
className={archived ? styles.checkpointArchived : undefined}
onClick={() => onSelect(d.id)}
style={{ cursor: 'pointer' }}
>
<td><Badge label={v ? `v${v.version}` : '?'} color="auto" /></td>
<td className={styles.jarCell}>
{v ? <span className={styles.jarName}>{v.jarFilename}</span>
: <><span className={styles.jarStrike}>JAR pruned</span>
<div className={styles.archivedHint}>archived — JAR pruned</div></>}
</td>
<td>{d.createdBy ?? <span className={styles.muted}>—</span>}</td>
<td>
{d.deployedAt && timeAgo(d.deployedAt)}
<div className={styles.isoSubline}>{d.deployedAt}</div>
</td>
<td>
<span className={styles.strategyPill}>
{d.deploymentStrategy === 'BLUE_GREEN' ? 'blue/green' : 'rolling'}
</span>
</td>
<td>
<span className={`${styles.outcomePill} ${styles[`outcome-${d.status}`]}`}>
{d.status}
</span>
</td>
<td className={styles.chevron}>›</td>
</tr>
);
})}
</tbody>
</table>
{hidden > 0 && !expanded && (
<button type="button" className={styles.showOlderBtn} onClick={() => setExpanded(true)}>
Show older ({hidden}) — archived, postmortem only
</button>
)}
</div>
);
}
- Step 4: Add styles to
AppDeploymentPage.module.css
Add these classes (use existing tokens from the existing .checkpointRow, etc. styles for consistency):
.checkpointsTable table { width: 100%; border-collapse: collapse; font-size: 13px; }
.checkpointsTable th {
text-align: left; padding: 10px 12px; font-weight: 600; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted);
background: var(--bg-subtle); border-bottom: 1px solid var(--border);
}
.checkpointsTable td { padding: 12px; border-top: 1px solid var(--border-subtle); vertical-align: top; }
.checkpointsTable tbody tr:hover { background: var(--bg-hover); }
.checkpointArchived { opacity: 0.55; }
.jarCell { font-family: var(--font-mono); font-size: 12px; }
.jarStrike { text-decoration: line-through; }
.archivedHint { font-size: 11px; color: var(--amber); }
.isoSubline { font-size: 11px; color: var(--text-muted); }
.muted { color: var(--text-muted); }
.strategyPill, .outcomePill {
display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 11px;
background: var(--bg-subtle);
}
.outcome-STOPPED { color: var(--text-muted); }
.outcome-DEGRADED { background: var(--amber-bg); color: var(--amber); }
.chevron { color: var(--text-muted); font-size: 14px; }
.showOlderBtn {
width: 100%; padding: 10px; background: transparent; border: 0;
color: var(--text-muted); cursor: pointer; font-size: 12px;
}
.showOlderBtn:hover { background: var(--bg-hover); color: var(--text); }
- Step 5: Run tests to verify they pass
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx
Expected: all 8 tests PASS.
- Step 6: Type-check
cd ui && npm run typecheck
Expected: zero errors.
- Step 7: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx \
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx \
ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css
git commit -m "feat(ui): CheckpointsTable component (replaces row list)"
Task 10: Build CheckpointDetailDrawer — container + LogsPanel
Files:
-
Create:
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx -
Create:
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/LogsPanel.tsx -
Create:
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/instance-id.ts(helper to derive instance_ids from a deployment) -
Create:
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx -
Step 1: Write the failing tests
// CheckpointDetailDrawer.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { CheckpointDetailDrawer } from './index';
import { instanceIdsFor } from './instance-id';
describe('instanceIdsFor', () => {
it('derives N instance_ids from replicaStates + deployment id', () => {
expect(instanceIdsFor({
id: 'aaa11111-2222-3333-4444-555555555555',
replicaStates: [{ index: 0 }, { index: 1 }, { index: 2 }],
} as any, 'prod', 'my-app')).toEqual([
'prod-my-app-0-aaa11111',
'prod-my-app-1-aaa11111',
'prod-my-app-2-aaa11111',
]);
});
it('returns empty array when no replicas', () => {
expect(instanceIdsFor({ id: 'x', replicaStates: [] } as any, 'e', 'a')).toEqual([]);
});
});
describe('CheckpointDetailDrawer', () => {
const baseDep: any = {
id: 'aaa11111-2222-3333-4444-555555555555',
appVersionId: 'v6id', status: 'STOPPED', deploymentStrategy: 'BLUE_GREEN',
replicaStates: [{ index: 0 }], createdBy: 'alice',
deployedAt: '2026-04-23T10:35:00Z', stoppedAt: '2026-04-23T10:52:00Z',
deployedConfigSnapshot: { jarVersionId: 'v6id', agentConfig: null, containerConfig: {}, sensitiveKeys: null },
};
const v: any = { id: 'v6id', version: 6, jarFilename: 'my-app-1.2.3.jar' };
function renderDrawer(props: Partial<any> = {}) {
const qc = new QueryClient();
return render(
<QueryClientProvider client={qc}>
<CheckpointDetailDrawer
open
onClose={() => {}}
deployment={baseDep}
version={v}
appSlug="my-app"
envSlug="prod"
onRestore={() => {}}
{...props}
/>
</QueryClientProvider>
);
}
it('renders header with version + jar + outcome', () => {
renderDrawer();
expect(screen.getByText(/v6/)).toBeInTheDocument();
expect(screen.getByText(/my-app-1\.2\.3\.jar/)).toBeInTheDocument();
expect(screen.getByText(/STOPPED/)).toBeInTheDocument();
});
it('renders meta line with createdBy', () => {
renderDrawer();
expect(screen.getByText(/alice/)).toBeInTheDocument();
});
it('Logs tab is selected by default', () => {
renderDrawer();
expect(screen.getByRole('tab', { name: /logs/i, selected: true })).toBeInTheDocument();
});
it('disables Restore when JAR is pruned', () => {
renderDrawer({ version: undefined });
expect(screen.getByRole('button', { name: /restore/i })).toBeDisabled();
});
});
- Step 2: Run tests to verify they fail
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer
Expected: all tests fail (modules not found).
- Step 3: Implement
instance-id.ts
import type { Deployment } from '../../../../api/queries/admin/apps';
export function instanceIdsFor(
deployment: Pick<Deployment, 'id' | 'replicaStates'>,
envSlug: string,
appSlug: string,
): string[] {
const generation = deployment.id.replace(/-/g, '').slice(0, 8);
return deployment.replicaStates.map((r) =>
`${envSlug}-${appSlug}-${r.index}-${generation}`
);
}
Note: confirm the generation format matches the backend. Per
docker-orchestration.md: "generation is the first 8 characters of the deployment UUID". UUIDs are typically rendered with hyphens (e.g.aaa11111-2222-...); the first 8 chars (no hyphens) are the leading 8 hex chars. Adjust if the backend uses with-hyphens slicing.
- Step 4: Implement
LogsPanel.tsx
import { useMemo, useState } from 'react';
import { LogViewer } from '@cameleer/design-system';
import { useInfiniteApplicationLogs } from '../../../../api/queries/logs';
import type { Deployment } from '../../../../api/queries/admin/apps';
import { instanceIdsFor } from './instance-id';
interface Props {
deployment: Deployment;
appSlug: string;
envSlug: string;
}
export function LogsPanel({ deployment, appSlug, envSlug }: Props) {
const allInstanceIds = useMemo(
() => instanceIdsFor(deployment, envSlug, appSlug),
[deployment, envSlug, appSlug]
);
const [replicaFilter, setReplicaFilter] = useState<'all' | number>('all');
const filteredInstanceIds = replicaFilter === 'all'
? allInstanceIds
: allInstanceIds.filter((_, i) => i === replicaFilter);
const logs = useInfiniteApplicationLogs({
appSlug, envSlug, instanceIds: filteredInstanceIds,
});
return (
<div>
<div /* filter bar */>
<select
value={String(replicaFilter)}
onChange={(e) =>
setReplicaFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
}
>
<option value="all">all ({deployment.replicaStates.length})</option>
{deployment.replicaStates.map((r) => (
<option key={r.index} value={r.index}>{r.index}</option>
))}
</select>
</div>
<LogViewer entries={logs.items} loading={logs.isLoading}
onLoadMore={logs.hasMore ? logs.loadMore : undefined} />
</div>
);
}
- Step 5: Implement
index.tsx(drawer container)
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 = (
<div>
<Badge label={version ? `v${version.version}` : '?'} color="auto" />{' '}
<span style={{ fontFamily: 'var(--font-mono)' }}>
{version?.jarFilename ?? 'JAR pruned'}
</span>{' '}
<span>{deployment.status}</span>
</div>
);
const footer = (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
Restoring hydrates the form — you'll still need to Redeploy.
</span>
<Button
variant="primary"
disabled={archived}
title={archived ? 'JAR was pruned by the environment retention policy' : undefined}
onClick={() => { onRestore(deployment.id); onClose(); }}
>
Restore this checkpoint
</Button>
</div>
);
return (
<SideDrawer open={open} onClose={onClose} title={title} size="lg" footer={footer}>
<div /* meta line */>
Deployed by <b>{deployment.createdBy ?? '—'}</b> ·{' '}
{deployment.deployedAt} · Strategy: {deployment.deploymentStrategy} ·{' '}
{deployment.replicaStates.length} replicas
</div>
<Tabs
active={tab}
onChange={(t) => setTab(t as 'logs' | 'config')}
tabs={[
{ id: 'logs', label: 'Logs' },
{ id: 'config', label: 'Config' },
]}
/>
{tab === 'logs' && (
<LogsPanel deployment={deployment} appSlug={appSlug} envSlug={envSlug} />
)}
{tab === 'config' && (
<ConfigPanel deployment={deployment} appSlug={appSlug} envSlug={envSlug}
archived={archived} />
)}
</SideDrawer>
);
}
(Stub ConfigPanel for now to satisfy import — will be implemented in Task 11.)
// 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 <div>Config panel — implemented in next task</div>;
}
- Step 6: Run tests to verify they pass
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer
Expected: all PASS.
- Step 7: Type-check
cd ui && npm run typecheck
Expected: zero errors.
- Step 8: Commit
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
// 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
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/diff.test.ts
Expected: all 6 tests fail.
- Step 3: Implement
diff.ts
export interface FieldDiff {
path: string;
oldValue: unknown;
newValue: unknown;
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
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
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/diff.test.ts
Expected: PASS.
- Step 5: Write
ConfigPanel.test.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 <QueryClientProvider client={qc}>{node}</QueryClientProvider>;
}
describe('ConfigPanel', () => {
it('renders the 5 read-only sub-tabs in Snapshot mode', () => {
render(wrap(<ConfigPanel deployment={dep} appSlug="a" envSlug="e" archived={false} />));
['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(<ConfigPanel deployment={dep} appSlug="a" envSlug="e" archived />));
expect(screen.queryByRole('button', { name: /diff/i })).toBeNull();
});
it('shows the toggle when not archived', () => {
render(wrap(<ConfigPanel deployment={dep} appSlug="a" envSlug="e" archived={false} />));
expect(screen.getByText(/snapshot/i)).toBeInTheDocument();
expect(screen.getByText(/diff vs current/i)).toBeInTheDocument();
});
});
- Step 6: Run test to verify it fails
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
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<Mode>('snapshot');
const [subTab, setSubTab] = useState<SubTab>('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<SubTab, number> = {
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 (
<div>
{!archived && (
<SegmentedTabs
tabs={[{ id: 'snapshot', label: 'Snapshot' },
{ id: 'diff', label: `Diff vs current${diff.length ? ` (${diff.length})` : ''}` }]}
active={mode}
onChange={(m) => setMode(m as Mode)}
/>
)}
<Tabs
active={subTab}
onChange={(t) => 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' && <ResourcesTab readOnly /* + snapshot-derived props */ />}
{subTab === 'monitoring' && <MonitoringTab readOnly /* + snapshot-derived props */ />}
{subTab === 'variables' && <VariablesTab readOnly /* + snapshot-derived props */ />}
{subTab === 'sensitive' && <SensitiveKeysTab readOnly /* + snapshot-derived props */ />}
{subTab === 'deployment' && <DeploymentTabConfig readOnly /* + snapshot-derived props */ />}
</>
)}
{mode === 'diff' && (
<DiffView diffs={diffsForTab(diff, subTab)} />
)}
</div>
);
}
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 <div style={{ color: 'var(--text-muted)', padding: 16 }}>
No differences in this section.
</div>;
}
return (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>
{diffs.map((d) => (
<div key={d.path} style={{ marginBottom: 8 }}>
<div style={{ color: 'var(--text-muted)' }}>{d.path}</div>
<div style={{ background: 'var(--red-bg)', borderLeft: '2px solid var(--red)', padding: '2px 6px' }}>
- {JSON.stringify(d.oldValue)}
</div>
<div style={{ background: 'var(--green-bg)', borderLeft: '2px solid var(--green)', padding: '2px 6px' }}>
+ {JSON.stringify(d.newValue)}
</div>
</div>
))}
</div>
);
}
The exact prop signature each ConfigTab needs to receive depends on how the live form passes them today. Open the existing
ConfigTabs/*.tsxfiles to see what props they consume in the live page (form state, change handlers, derived values), and pass the snapshot-derived equivalents. ThereadOnlyprop added in Task 8 lets you safely pass mock change handlers / disabled state.
- Step 8: Run tests to verify they pass
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.test.tsx
Expected: all PASS.
- Step 9: Commit
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.tsxto find the existing Checkpoints integration
Locate where <Checkpoints ... /> is rendered. Note the props it receives.
- Step 2: Replace the import + rendering
// 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<string | null>(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 <Checkpoints ... /> used to be:
<CheckpointsTable
deployments={deployments}
versions={versions}
currentDeploymentId={currentDeploymentId}
jarRetentionCount={jarRetentionCount}
onSelect={setSelectedCheckpointId}
/>
{selectedDep && (
<CheckpointDetailDrawer
open
onClose={() => setSelectedCheckpointId(null)}
deployment={selectedDep}
version={selectedVersion}
appSlug={appSlug}
envSlug={envSlug}
onRestore={onRestore}
/>
)}
- Step 3: Delete the old Checkpoints component
rm ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx
- Step 4: Type-check
cd ui && npm run typecheck
Expected: zero errors. (If the env hook name differs, adjust the import path.)
- Step 5: Run all UI tests
cd ui && npm run test
Expected: all PASS.
- Step 6: Manual smoke test
Start backend + UI dev:
# 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
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 <Select> (or <select>) bound to the category filter. It will have an array of options like ['INFRA', 'AUTH', 'USER_MGMT', ...].
- Step 2: Add
DEPLOYMENToption
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
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:
- `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:
- `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:
- 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:
- `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
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
mvn clean verify
Expected: BUILD SUCCESS with all unit tests + ITs green.
- Step 2: Full UI build + tests
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":
- Deploy app v1 → expect audit row
deploy_appSUCCESS with actor=alice, category=DEPLOYMENT - Deploy app v2 → previous deployment becomes a checkpoint
- Open the Checkpoints table → expect v1 row with deployer=alice
- Click the v1 row → drawer opens
- Logs tab → see logs for v1's instance_ids only
- Config tab → snapshot mode shows v1 config; toggle Diff → see differences vs current v2
- Stop v2 → audit row
stop_deploymentSUCCESS - Restore v1 from drawer → form hydrates; click Redeploy → audit row
deploy_appSUCCESS - 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.