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>
2178 lines
78 KiB
Markdown
2178 lines
78 KiB
Markdown
# 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.
|