Files
cameleer-server/docs/superpowers/plans/2026-04-23-checkpoints-table-redesign.md

2178 lines
78 KiB
Markdown
Raw Normal View History

# 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<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`:
```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<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 `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<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` + `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<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(...)`:
```java
@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(...)`:
```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<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 `instanceIds` to `LogSearchRequest`**
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:
```java
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:
```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(<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**
```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(
<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`:
```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 (`<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`:
```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**
```bash
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**
```bash
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/ConfigTabs
```
Expected: all PASS.
- [ ] **Step 6: Type-check**
```bash
cd ui && npm run typecheck
```
Expected: zero errors.
- [ ] **Step 7: Commit**
```bash
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**
```tsx
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**
```bash
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`:
```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):
```css
.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**
```bash
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx
```
Expected: all 8 tests PASS.
- [ ] **Step 6: Type-check**
```bash
cd ui && npm run typecheck
```
Expected: zero errors.
- [ ] **Step 7: Commit**
```bash
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**
```tsx
// 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**
```bash
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer
```
Expected: all tests fail (modules not found).
- [ ] **Step 3: Implement `instance-id.ts`**
```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`**
```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)**
```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 = (
<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.)
```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 <div>Config panel — implemented in next task</div>;
}
```
- [ ] **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<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**
```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 <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**
```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<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/*.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 `<Checkpoints ... />` 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<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**
```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 `<Select>` (or `<select>`) 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.