Files
cameleer-server/docs/superpowers/plans/2026-04-23-checkpoints-table-redesign.md
hsiegeln e558494f8d plan(deploy): checkpoints table redesign + audit gap
15 tasks across 5 phases (backend foundation → SideDrawer →
ConfigTabs readOnly → CheckpointsTable + DetailDrawer → polish).
TDD throughout with per-task commits. Backend phase ships
independently to close the audit gap as quickly as possible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:39:11 +02:00

2178 lines
78 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Checkpoints table redesign + deployment audit gap — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the cramped Checkpoints disclosure on the deployment page with a real DataTable + side drawer (Logs / Config with snapshot+diff modes), and close the deployment audit-log gap (deploy/stop/promote currently emit zero audit rows).
**Architecture:** Phase 1 lays a backend foundation (V2 migration adds `created_by` on `deployments`, new `AuditCategory.DEPLOYMENT`, audit calls in `DeploymentController`, `instanceIds` filter on `LogQueryController`). Phase 2 builds a project-local `SideDrawer` primitive. Phase 3 makes the existing config sub-tabs read-only-capable. Phase 4 builds the new `CheckpointsTable` and `CheckpointDetailDrawer`. Phase 5 wires polish (admin filter dropdown, rules docs).
**Tech Stack:** Java 17, Spring Boot 3.4.3, JdbcTemplate, Flyway, JUnit 5 + Testcontainers (Postgres 16 + ClickHouse 24.12); React 19 + TypeScript, Vite, TanStack Query, Zustand, Vitest + React Testing Library, `@cameleer/design-system`.
**Spec:** `docs/superpowers/specs/2026-04-23-checkpoints-table-redesign-design.md`
---
## Phase 1 — Backend foundation
### Task 1: Flyway V2 migration adds `deployments.created_by`
**Files:**
- Create: `cameleer-server-app/src/main/resources/db/migration/V2__add_deployment_created_by.sql`
- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/storage/V2DeploymentCreatedByMigrationIT.java`
- [ ] **Step 1: Write the failing IT**
```java
package com.cameleer.server.app.storage;
import com.cameleer.server.app.AbstractPostgresIT;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class V2DeploymentCreatedByMigrationIT extends AbstractPostgresIT {
@Autowired JdbcTemplate jdbc;
@Test
void created_by_column_exists_with_correct_type_and_fk() {
List<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=V2DeploymentCreatedByMigrationIT`
Expected: tests FAIL — column does not exist.
- [ ] **Step 3: Create the migration file**
`cameleer-server-app/src/main/resources/db/migration/V2__add_deployment_created_by.sql`:
```sql
ALTER TABLE deployments
ADD COLUMN created_by TEXT REFERENCES users(user_id);
CREATE INDEX idx_deployments_created_by ON deployments (created_by);
```
- [ ] **Step 4: Run test to verify it passes**
Run: `mvn -pl cameleer-server-app verify -Dit.test=V2DeploymentCreatedByMigrationIT`
Expected: all 3 tests PASS.
- [ ] **Step 5: Commit**
```bash
git add cameleer-server-app/src/main/resources/db/migration/V2__add_deployment_created_by.sql \
cameleer-server-app/src/test/java/com/cameleer/server/app/storage/V2DeploymentCreatedByMigrationIT.java
git commit -m "feat(deploy): V2 migration — add created_by to deployments"
```
---
### Task 2: Add `AuditCategory.DEPLOYMENT`
**Files:**
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java`
- Modify: `cameleer-server-core/src/test/java/com/cameleer/server/core/admin/AuditCategoryTest.java`
- [ ] **Step 1: Write/update the failing test**
In `AuditCategoryTest.java`, add (or merge with the existing exhaustive enumeration test):
```java
@Test
void deployment_category_exists() {
assertThat(AuditCategory.valueOf("DEPLOYMENT"))
.isEqualTo(AuditCategory.DEPLOYMENT);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `mvn -pl cameleer-server-core test -Dtest=AuditCategoryTest`
Expected: FAIL — `DEPLOYMENT` not declared.
- [ ] **Step 3: Add the enum value**
Edit `AuditCategory.java`:
```java
package com.cameleer.server.core.admin;
public enum AuditCategory {
INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT,
OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE,
ALERT_RULE_CHANGE, ALERT_SILENCE_CHANGE,
DEPLOYMENT
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `mvn -pl cameleer-server-core test -Dtest=AuditCategoryTest`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java \
cameleer-server-core/src/test/java/com/cameleer/server/core/admin/AuditCategoryTest.java
git commit -m "feat(audit): add DEPLOYMENT audit category"
```
---
### Task 3: Extend `Deployment` record + `DeploymentService` signature with `createdBy`
This task is the largest in Phase 1 because the field ripples through one record + one interface + one service impl + one repository + many tests. Allocate ~30 min. Run the full unit-test suite at the end to catch ripple breaks.
**Files:**
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Deployment.java`
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentService.java`
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentServiceImpl.java` (or wherever the impl lives — verify with `Grep "class DeploymentServiceImpl|implements DeploymentService"`)
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java`
- Modify: any other constructor call sites surfaced by the compiler (the compiler is your todo list)
- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/storage/PostgresDeploymentRepositoryCreatedByIT.java`
- [ ] **Step 1: Write the failing IT**
```java
package com.cameleer.server.app.storage;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class PostgresDeploymentRepositoryCreatedByIT extends AbstractPostgresIT {
@Autowired DeploymentService deploymentService;
@Autowired JdbcTemplate jdbc;
private UUID appId;
private UUID envId;
private UUID versionId;
@BeforeEach
void seedAppAndVersion() {
// Use the seeded default environment from V1
envId = jdbc.queryForObject(
"SELECT id FROM environments WHERE slug = 'default'", UUID.class);
// Seed user
jdbc.update("INSERT INTO users (user_id, password_hash, source) VALUES (?, '', 'LOCAL') " +
"ON CONFLICT (user_id) DO NOTHING", "alice");
// Seed app
appId = UUID.randomUUID();
jdbc.update("INSERT INTO apps (id, environment_id, app_slug, display_name, container_config) " +
"VALUES (?, ?, 'test-app', 'Test App', '{}'::jsonb)",
appId, envId);
// Seed version
versionId = UUID.randomUUID();
jdbc.update("INSERT INTO app_versions (id, app_id, version, jar_path, jar_checksum, " +
"jar_filename, jar_size_bytes) " +
"VALUES (?, ?, 1, '/tmp/x.jar', 'abc', 'x.jar', 100)",
versionId, appId);
}
@Test
void createDeployment_persists_createdBy_and_returns_it() {
Deployment d = deploymentService.createDeployment(appId, versionId, envId, "alice");
assertThat(d.createdBy()).isEqualTo("alice");
String fromDb = jdbc.queryForObject(
"SELECT created_by FROM deployments WHERE id = ?", String.class, d.id());
assertThat(fromDb).isEqualTo("alice");
}
@Test
void promote_persists_createdBy() {
Deployment promoted = deploymentService.promote(appId, versionId, envId, "bob");
assertThat(promoted.createdBy()).isEqualTo("bob");
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `mvn -pl cameleer-server-app verify -Dit.test=PostgresDeploymentRepositoryCreatedByIT`
Expected: COMPILE FAIL — `Deployment.createdBy()` doesn't exist; `createDeployment(...)` arity wrong.
- [ ] **Step 3: Add `createdBy` to the `Deployment` record**
Edit `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Deployment.java`. The current record carries id, appId, appVersionId, environmentId, status, etc. Add `String createdBy` as the **last** field (additive, keeps the existing positional order intact for the rest):
```java
public record Deployment(
UUID id,
UUID appId,
UUID appVersionId,
UUID environmentId,
DeploymentStatus status,
String targetState,
String deploymentStrategy,
DeployStage deployStage,
List<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.