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

78 KiB
Raw Blame History

Checkpoints table redesign + deployment audit gap — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the cramped Checkpoints disclosure on the deployment page with a real DataTable + side drawer (Logs / Config with snapshot+diff modes), and close the deployment audit-log gap (deploy/stop/promote currently emit zero audit rows).

Architecture: Phase 1 lays a backend foundation (V2 migration adds created_by on deployments, new AuditCategory.DEPLOYMENT, audit calls in DeploymentController, instanceIds filter on LogQueryController). Phase 2 builds a project-local SideDrawer primitive. Phase 3 makes the existing config sub-tabs read-only-capable. Phase 4 builds the new CheckpointsTable and CheckpointDetailDrawer. Phase 5 wires polish (admin filter dropdown, rules docs).

Tech Stack: Java 17, Spring Boot 3.4.3, JdbcTemplate, Flyway, JUnit 5 + Testcontainers (Postgres 16 + ClickHouse 24.12); React 19 + TypeScript, Vite, TanStack Query, Zustand, Vitest + React Testing Library, @cameleer/design-system.

Spec: docs/superpowers/specs/2026-04-23-checkpoints-table-redesign-design.md


Phase 1 — Backend foundation

Task 1: Flyway V2 migration adds deployments.created_by

Files:

  • Create: cameleer-server-app/src/main/resources/db/migration/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

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:

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
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):

@Test
void deployment_category_exists() {
    assertThat(AuditCategory.valueOf("DEPLOYMENT"))
        .isEqualTo(AuditCategory.DEPLOYMENT);
}
  • Step 2: Run test to verify it fails

Run: mvn -pl cameleer-server-core test -Dtest=AuditCategoryTest Expected: FAIL — DEPLOYMENT not declared.

  • Step 3: Add the enum value

Edit AuditCategory.java:

package com.cameleer.server.core.admin;

public enum AuditCategory {
    INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT,
    OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE,
    ALERT_RULE_CHANGE, ALERT_SILENCE_CHANGE,
    DEPLOYMENT
}
  • Step 4: Run test to verify it passes

Run: mvn -pl cameleer-server-core test -Dtest=AuditCategoryTest Expected: PASS.

  • Step 5: Commit
git add cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java \
        cameleer-server-core/src/test/java/com/cameleer/server/core/admin/AuditCategoryTest.java
git commit -m "feat(audit): add DEPLOYMENT audit category"

Task 3: Extend Deployment record + DeploymentService signature with createdBy

This task is the largest in Phase 1 because the field ripples through one record + one interface + one service impl + one repository + many tests. Allocate ~30 min. Run the full unit-test suite at the end to catch ripple breaks.

Files:

  • Modify: cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Deployment.java

  • Modify: cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentService.java

  • Modify: cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentServiceImpl.java (or wherever the impl lives — verify 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

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):

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:

Deployment createDeployment(UUID appId, UUID appVersionId, UUID environmentId, String createdBy);

Deployment promote(UUID targetAppId, UUID sourceVersionId, UUID targetEnvironmentId, String createdBy);
  • Step 5: Update the service impl

Locate the impl (Grep "implements DeploymentService" if unsure). Update the two methods to accept and forward createdBy to the repository.

  • Step 6: Update PostgresDeploymentRepository

In the INSERT INTO deployments(...) VALUES(...) statement, add created_by to the column list and ? to the values list, binding the new createdBy parameter. In the RowMapper for Deployment, read rs.getString("created_by") and pass it as the new last constructor arg.

  • Step 7: Compile to find ripple

Run: mvn -pl cameleer-server-app -am compile Expected: list of broken call sites (DeploymentController, tests). Add the new arg at every call site — for now, in tests, pass "test-user". The controller fix is Task 4; for now, hardcode "test-user" there too so the build compiles.

  • Step 8: Run the failing IT

Run: mvn -pl cameleer-server-app verify -Dit.test=PostgresDeploymentRepositoryCreatedByIT Expected: PASS.

  • Step 9: Run all unit tests to catch ripple regressions

Run: mvn -pl cameleer-server-app verify -DskipITs Expected: 205+ tests pass (matches Phase 0 baseline).

  • Step 10: Commit
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Deployment.java \
        cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentService.java \
        cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentServiceImpl.java \
        cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java \
        cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java \
        cameleer-server-app/src/test/java/com/cameleer/server/app/storage/PostgresDeploymentRepositoryCreatedByIT.java
# Plus any other modified test files surfaced by the compiler
git commit -m "feat(deploy): add createdBy to Deployment record + service + repo"

Task 4: DeploymentController — audit calls + resolve createdBy from SecurityContextHolder

Files:

  • Modify: cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java

  • Test: cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerAuditIT.java

  • Step 1: Write the failing IT

package com.cameleer.server.app.controller;

import com.cameleer.server.app.AbstractPostgresIT;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.jdbc.core.JdbcTemplate;

import java.util.List;
import java.util.Map;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;

class DeploymentControllerAuditIT extends AbstractPostgresIT {

    @Autowired TestRestTemplate rest;
    @Autowired JdbcTemplate jdbc;
    // Helper to mint an OPERATOR JWT for "alice" — copy the pattern used by
    // an existing controller IT (e.g. AppControllerIT). If a shared helper
    // exists (e.g. JwtTestSupport), use it.

    private UUID envId;
    private UUID appId;
    private UUID versionId;
    private String aliceJwt;

    @BeforeEach
    void seed() {
        // Same seed pattern as PostgresDeploymentRepositoryCreatedByIT.
        // Plus: mint aliceJwt as a user with ROLE_OPERATOR.
    }

    @Test
    void deploy_writes_audit_row_with_DEPLOYMENT_category_and_alice_actor() {
        HttpHeaders h = new HttpHeaders();
        h.setBearerAuth(aliceJwt);
        var body = Map.of("appVersionId", versionId.toString());
        var resp = rest.exchange(
            "/api/v1/environments/default/apps/test-app/deployments",
            HttpMethod.POST, new HttpEntity<>(body, h), Map.class);

        assertThat(resp.getStatusCode().is2xxSuccessful()).isTrue();

        List<Map<String, Object>> rows = jdbc.queryForList(
            "SELECT actor, action, category, target_id, result " +
            "FROM audit_log WHERE action = 'deploy_app' ORDER BY ts DESC LIMIT 1");
        assertThat(rows).hasSize(1);
        assertThat(rows.get(0))
            .containsEntry("actor", "alice")
            .containsEntry("action", "deploy_app")
            .containsEntry("category", "DEPLOYMENT")
            .containsEntry("result", "SUCCESS");
        assertThat((String) rows.get(0).get("target_id")).isNotBlank();
    }

    @Test
    void stop_writes_audit_row() {
        // ... arrange a deployment, hit POST /{id}/stop, assert audit_log row
    }

    @Test
    void promote_writes_audit_row() {
        // ... arrange another env, hit POST /{id}/promote, assert audit_log row
    }

    @Test
    void deploy_with_unknown_app_writes_FAILURE_audit_row() {
        // 404 path — assert audit_log row with result=FAILURE
    }
}

Note: If your existing controller ITs use a different test framework/helper for JWT minting, follow that pattern instead. The above is a sketch — adapt to local conventions.

  • Step 2: Run test to verify it fails

Run: mvn -pl cameleer-server-app verify -Dit.test=DeploymentControllerAuditIT Expected: tests FAIL — no audit rows written.

  • Step 3: Wire AuditService + createdBy into DeploymentController

Edit DeploymentController.java:

import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import org.springframework.security.core.context.SecurityContextHolder;
import jakarta.servlet.http.HttpServletRequest;

private final AuditService auditService;

// Add to constructor params + assignment.

private static String currentUserId() {
    String name = SecurityContextHolder.getContext().getAuthentication().getName();
    return name != null && name.startsWith("user:") ? name.substring(5) : name;
}

For deploy(...):

@PostMapping
public ResponseEntity<Deployment> deploy(@EnvPath Environment env,
                                          @PathVariable String appSlug,
                                          @RequestBody DeployRequest request,
                                          HttpServletRequest httpRequest) {
    String createdBy = currentUserId();
    try {
        App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
        Deployment deployment = deploymentService.createDeployment(
            app.id(), request.appVersionId(), env.id(), createdBy);
        deploymentExecutor.executeAsync(deployment);

        // Look up version for audit details
        AppVersion version = appVersionService.getById(request.appVersionId());
        auditService.log(createdBy, "deploy_app", AuditCategory.DEPLOYMENT,
            deployment.id().toString(),
            Map.of("appSlug", appSlug, "envSlug", env.slug(),
                   "appVersionId", request.appVersionId().toString(),
                   "jarFilename", version.jarFilename(),
                   "version", version.version()),
            AuditResult.SUCCESS, httpRequest);
        return ResponseEntity.accepted().body(deployment);
    } catch (IllegalArgumentException e) {
        auditService.log(createdBy, "deploy_app", AuditCategory.DEPLOYMENT,
            null, Map.of("appSlug", appSlug, "envSlug", env.slug(),
                         "error", e.getMessage()),
            AuditResult.FAILURE, httpRequest);
        return ResponseEntity.notFound().build();
    }
}

For stop(...):

@PostMapping("/{deploymentId}/stop")
public ResponseEntity<Deployment> stop(@EnvPath Environment env,
                                        @PathVariable String appSlug,
                                        @PathVariable UUID deploymentId,
                                        HttpServletRequest httpRequest) {
    String actor = currentUserId();
    try {
        Deployment deployment = deploymentService.getById(deploymentId);
        deploymentExecutor.stopDeployment(deployment);
        auditService.log(actor, "stop_deployment", AuditCategory.DEPLOYMENT,
            deploymentId.toString(),
            Map.of("appSlug", appSlug, "envSlug", env.slug()),
            AuditResult.SUCCESS, httpRequest);
        return ResponseEntity.ok(deploymentService.getById(deploymentId));
    } catch (IllegalArgumentException e) {
        auditService.log(actor, "stop_deployment", AuditCategory.DEPLOYMENT,
            deploymentId.toString(),
            Map.of("appSlug", appSlug, "envSlug", env.slug(), "error", e.getMessage()),
            AuditResult.FAILURE, httpRequest);
        return ResponseEntity.notFound().build();
    }
}

For promote(...):

@PostMapping("/{deploymentId}/promote")
public ResponseEntity<?> promote(@EnvPath Environment env,
                                  @PathVariable String appSlug,
                                  @PathVariable UUID deploymentId,
                                  @RequestBody PromoteRequest request,
                                  HttpServletRequest httpRequest) {
    String actor = currentUserId();
    try {
        App sourceApp = appService.getByEnvironmentAndSlug(env.id(), appSlug);
        Deployment source = deploymentService.getById(deploymentId);
        Environment targetEnv = environmentService.getBySlug(request.targetEnvironment());
        App targetApp = appService.getByEnvironmentAndSlug(targetEnv.id(), appSlug);
        Deployment promoted = deploymentService.promote(
            targetApp.id(), source.appVersionId(), targetEnv.id(), actor);
        deploymentExecutor.executeAsync(promoted);

        auditService.log(actor, "promote_deployment", AuditCategory.DEPLOYMENT,
            promoted.id().toString(),
            Map.of("sourceEnv", env.slug(), "targetEnv", targetEnv.slug(),
                   "appSlug", appSlug,
                   "appVersionId", source.appVersionId().toString()),
            AuditResult.SUCCESS, httpRequest);
        return ResponseEntity.accepted().body(promoted);
    } catch (IllegalArgumentException e) {
        auditService.log(actor, "promote_deployment", AuditCategory.DEPLOYMENT,
            deploymentId.toString(),
            Map.of("appSlug", appSlug, "error", e.getMessage()),
            AuditResult.FAILURE, httpRequest);
        return ResponseEntity.status(org.springframework.http.HttpStatus.NOT_FOUND)
                .body(Map.of("error", e.getMessage()));
    }
}

If the precise AuditService.log(...) overload signature differs from the example above, match the existing usage pattern 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
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

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:

List<String> instanceIdList = instanceIds == null || instanceIds.isBlank()
    ? List.of()
    : Arrays.stream(instanceIds.split(","))
            .map(String::trim).filter(s -> !s.isBlank()).toList();
LogSearchRequest req = new LogSearchRequest(/* existing args */, instanceIdList);
  • Step 5: Wire the filter into the ClickHouse query

In the log query implementation (typically ClickHouseLogStore.search(...) or similar — confirm via Grep "FROM logs" --include="*.java"), add when req.instanceIds() is non-empty:

if (!req.instanceIds().isEmpty()) {
    String placeholders = String.join(",", Collections.nCopies(req.instanceIds().size(), "?"));
    sql.append(" AND instance_id IN (").append(placeholders).append(")");
    params.addAll(req.instanceIds());
}

(Match the existing parameter-binding style used by the rest of the file — likely positional ? with a params list.)

  • Step 6: Run test to verify it passes

Run: mvn -pl cameleer-server-app verify -Dit.test=LogQueryControllerInstanceIdsIT Expected: PASS.

  • Step 7: Run all log-related ITs to catch regressions

Run: mvn -pl cameleer-server-app verify -Dit.test=*Log*IT Expected: all PASS.

  • Step 8: Commit
git add cameleer-server-core/src/main/java/com/cameleer/server/core/search/LogSearchRequest.java \
        cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LogQueryController.java \
        cameleer-server-app/src/main/java/com/cameleer/server/app/search/ClickHouseLogStore.java \
        cameleer-server-app/src/test/java/com/cameleer/server/app/controller/LogQueryControllerInstanceIdsIT.java
git commit -m "feat(logs): add instanceIds multi-value filter to /logs endpoint"

Task 6: Regenerate OpenAPI; surface createdBy and instanceIds in UI types

Files:

  • Regenerate: ui/src/api/openapi.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:

java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar

Wait until port 8081 is responsive.

  • Step 2: Regenerate from live backend
cd ui && npm run generate-api:live

This rewrites src/api/openapi.json and src/api/schema.d.ts.

  • Step 3: Update Deployment interface

In ui/src/api/queries/admin/apps.ts, add to the Deployment interface:

export interface Deployment {
  // ... existing fields ...
  createdBy: string | null;
}
  • Step 4: Update useInfiniteApplicationLogs

In ui/src/api/queries/logs.ts, add an optional instanceIds?: string[] parameter to the hook. When non-empty, append &instanceIds=${instanceIds.join(',')} to the query string. Add instanceIds to the TanStack Query key so different instance lists don't collide in cache.

  • Step 5: Type-check the SPA
cd ui && npm run typecheck

Expected: zero errors. (If Deployment.createdBy references in existing code break, fix at the call site — most readers will tolerate null.)

  • Step 6: Commit
git add ui/src/api/openapi.json ui/src/api/schema.d.ts \
        ui/src/api/queries/admin/apps.ts \
        ui/src/api/queries/logs.ts
git commit -m "chore(api): regenerate types — Deployment.createdBy + logs instanceIds"

Phase 2 — UI primitive: SideDrawer

Task 7: Build SideDrawer component with tests

Files:

  • Create: ui/src/components/SideDrawer.tsx

  • Create: ui/src/components/SideDrawer.module.css

  • Create: ui/src/components/SideDrawer.test.tsx

  • Step 1: Write the failing tests

// ui/src/components/SideDrawer.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { SideDrawer } from './SideDrawer';

describe('SideDrawer', () => {
  it('renders nothing when closed', () => {
    render(<SideDrawer open={false} onClose={() => {}} title="X">body</SideDrawer>);
    expect(screen.queryByText('body')).toBeNull();
  });

  it('renders title, body, and close button when open', () => {
    render(<SideDrawer open onClose={() => {}} title="My Title">body content</SideDrawer>);
    expect(screen.getByText('My Title')).toBeInTheDocument();
    expect(screen.getByText('body content')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
  });

  it('calls onClose when close button clicked', () => {
    const onClose = vi.fn();
    render(<SideDrawer open onClose={onClose} title="X">body</SideDrawer>);
    fireEvent.click(screen.getByRole('button', { name: /close/i }));
    expect(onClose).toHaveBeenCalledOnce();
  });

  it('calls onClose when ESC pressed', () => {
    const onClose = vi.fn();
    render(<SideDrawer open onClose={onClose} title="X">body</SideDrawer>);
    fireEvent.keyDown(document, { key: 'Escape' });
    expect(onClose).toHaveBeenCalledOnce();
  });

  it('calls onClose when backdrop clicked', () => {
    const onClose = vi.fn();
    render(<SideDrawer open onClose={onClose} title="X">body</SideDrawer>);
    fireEvent.click(screen.getByTestId('side-drawer-backdrop'));
    expect(onClose).toHaveBeenCalledOnce();
  });

  it('renders footer when provided', () => {
    render(
      <SideDrawer open onClose={() => {}} title="X" footer={<button>Save</button>}>
        body
      </SideDrawer>
    );
    expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
  });
});
  • Step 2: Run tests to verify they fail
cd ui && npx vitest run src/components/SideDrawer.test.tsx

Expected: all 6 tests fail (module not found).

  • Step 3: Implement the component

ui/src/components/SideDrawer.tsx:

import { useEffect, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import styles from './SideDrawer.module.css';

interface SideDrawerProps {
  open: boolean;
  onClose: () => void;
  title: ReactNode;
  size?: 'md' | 'lg' | 'xl';
  footer?: ReactNode;
  children: ReactNode;
}

export function SideDrawer({
  open, onClose, title, size = 'lg', footer, children,
}: SideDrawerProps) {
  useEffect(() => {
    if (!open) return;
    const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
    document.addEventListener('keydown', handler);
    return () => document.removeEventListener('keydown', handler);
  }, [open, onClose]);

  if (!open) return null;

  return createPortal(
    <div className={styles.root}>
      <div
        className={styles.backdrop}
        data-testid="side-drawer-backdrop"
        onClick={onClose}
      />
      <aside className={`${styles.drawer} ${styles[`size-${size}`]}`} role="dialog" aria-modal="true">
        <header className={styles.header}>
          <div className={styles.title}>{title}</div>
          <button
            type="button"
            aria-label="Close drawer"
            className={styles.closeBtn}
            onClick={onClose}
          >×</button>
        </header>
        <div className={styles.body}>{children}</div>
        {footer && <footer className={styles.footer}>{footer}</footer>}
      </aside>
    </div>,
    document.body
  );
}
  • Step 4: Implement the CSS

ui/src/components/SideDrawer.module.css:

.root {
  position: fixed;
  inset: 0;
  z-index: 950;
  pointer-events: none;
}
.backdrop {
  position: absolute;
  inset: 0;
  pointer-events: auto;
  /* transparent — no dim */
}
.drawer {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  background: var(--bg);
  border-left: 1px solid var(--border);
  box-shadow: -8px 0 24px var(--shadow-lg, rgba(0, 0, 0, 0.25));
  display: flex;
  flex-direction: column;
  pointer-events: auto;
  animation: slideIn 240ms ease-out;
}
.size-md { width: 560px; max-width: 100vw; }
.size-lg { width: 720px; max-width: 100vw; }
.size-xl { width: 900px; max-width: 100vw; }
.header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  padding: 14px 18px;
  border-bottom: 1px solid var(--border);
  flex-shrink: 0;
}
.title { flex: 1; font-weight: 600; }
.closeBtn {
  background: transparent;
  border: 0;
  font-size: 24px;
  line-height: 1;
  color: var(--text-muted);
  cursor: pointer;
  padding: 0 4px;
}
.closeBtn:hover { color: var(--text); }
.body { flex: 1; overflow-y: auto; padding: 16px 18px; }
.footer {
  flex-shrink: 0;
  padding: 14px 18px;
  border-top: 1px solid var(--border);
  background: var(--bg-subtle);
}
@keyframes slideIn {
  from { transform: translateX(100%); }
  to { transform: translateX(0); }
}
  • Step 5: Run tests to verify they pass
cd ui && npx vitest run src/components/SideDrawer.test.tsx

Expected: all 6 tests PASS.

  • Step 6: Commit
git add ui/src/components/SideDrawer.tsx \
        ui/src/components/SideDrawer.module.css \
        ui/src/components/SideDrawer.test.tsx
git commit -m "feat(ui): add SideDrawer component (project-local)"

Phase 3 — UI: ConfigTabs readOnly? props

Task 8: Add readOnly? prop to all five ConfigTabs

Each of the five tabs gets the same minimal change. Group them in one task — the change is mechanical.

Files (all under ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/):

  • Modify: MonitoringTab.tsx

  • Modify: ResourcesTab.tsx

  • Modify: VariablesTab.tsx

  • Modify: SensitiveKeysTab.tsx

  • Modify: LiveBanner.tsx (no-op when read-only — accept and check a prop)

  • Modify: the Deployment ConfigTab (verify exact filename via Glob)

  • Test: One smoke test per tab to assert read-only behavior

  • Step 1: Read each tab to understand the current shape

For each tab, identify:

  • The form-edit inputs
  • The Save / Apply / submit buttons
  • Any LiveBanner / "live mode" affordances

Add readOnly?: boolean to the props interface. When true:

  • Pass 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:

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ResourcesTab } from './ResourcesTab';

describe('ResourcesTab readOnly', () => {
  const baseProps = { /* fill with the minimum props the tab needs to render */ };

  it('renders inputs disabled when readOnly', () => {
    render(<ResourcesTab {...baseProps} readOnly />);
    screen.getAllByRole('textbox').forEach(el => {
      expect(el).toBeDisabled();
    });
  });

  it('hides Save button when readOnly', () => {
    render(<ResourcesTab {...baseProps} readOnly />);
    expect(screen.queryByRole('button', { name: /save/i })).toBeNull();
  });

  it('shows Save button when not readOnly', () => {
    render(<ResourcesTab {...baseProps} />);
    expect(screen.queryByRole('button', { name: /save/i })).toBeInTheDocument();
  });
});

Repeat for each of the other four tabs (small, mostly identical tests).

  • Step 3: Run tests to verify they fail
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/ConfigTabs

Expected: tests fail — readOnly prop ignored.

  • Step 4: Apply the prop in each tab

Per tab: add readOnly to props, pass disabled={readOnly} to each input/select, gate save buttons + LiveBanner with !readOnly.

If a tab has derived state that mixes with form state in a way that readOnly doesn't cleanly partition, refactor that tab now — don't ship leaky read-only behavior. Move derived state into a memoized selector or split the tab into a View and Edit component.

  • Step 5: Run tests to verify they pass
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/ConfigTabs

Expected: all PASS.

  • Step 6: Type-check
cd ui && npm run typecheck

Expected: zero errors.

  • Step 7: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/
git commit -m "feat(ui): readOnly prop on the 5 config tabs"

Phase 4 — UI: CheckpointsTable + CheckpointDetailDrawer

Task 9: Build CheckpointsTable

Files:

  • Create: ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx

  • Create: ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx

  • Modify: ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css (only if new styles needed)

  • Step 1: Write the failing tests

import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { CheckpointsTable } from './CheckpointsTable';
import type { Deployment, AppVersion } from '../../../api/queries/admin/apps';

const v6: AppVersion = { id: 'v6id', appId: 'a', version: 6, jarPath: '/j',
  jarChecksum: 'c', jarFilename: 'my-app-1.2.3.jar', jarSizeBytes: 1, detectedRuntimeType: null,
  detectedMainClass: null, uploadedAt: '2026-04-23T10:00:00Z' };

const stoppedDep: Deployment = {
  id: 'd1', appId: 'a', appVersionId: 'v6id', environmentId: 'e',
  status: 'STOPPED', targetState: 'STOPPED', deploymentStrategy: 'BLUE_GREEN',
  replicaStates: [{ index: 0, containerId: 'c', containerName: 'n', status: 'STOPPED' }],
  deployStage: null, containerId: null, containerName: null, errorMessage: null,
  deployedAt: '2026-04-23T10:35:00Z', stoppedAt: '2026-04-23T10:52:00Z',
  createdAt: '2026-04-23T10:35:00Z', createdBy: 'alice',
  deployedConfigSnapshot: { jarVersionId: 'v6id', agentConfig: null, containerConfig: {}, sensitiveKeys: null },
};

describe('CheckpointsTable', () => {
  it('renders a row per checkpoint with version, jar, deployer', () => {
    render(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
    expect(screen.getByText('v6')).toBeInTheDocument();
    expect(screen.getByText('my-app-1.2.3.jar')).toBeInTheDocument();
    expect(screen.getByText('alice')).toBeInTheDocument();
  });

  it('row click invokes onSelect with deploymentId', () => {
    const onSelect = vi.fn();
    render(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={5} onSelect={onSelect} />);
    fireEvent.click(screen.getByRole('row', { name: /v6/i }));
    expect(onSelect).toHaveBeenCalledWith('d1');
  });

  it('renders em-dash for null createdBy', () => {
    const noActor = { ...stoppedDep, createdBy: null };
    render(<CheckpointsTable deployments={[noActor]} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
    expect(screen.getByText('—')).toBeInTheDocument();
  });

  it('marks pruned-JAR rows as archived', () => {
    const pruned = { ...stoppedDep, appVersionId: 'unknown' };
    render(<CheckpointsTable deployments={[pruned]} versions={[]}
      currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
    expect(screen.getByText(/archived/i)).toBeInTheDocument();
  });

  it('excludes the currently-running deployment', () => {
    render(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
      currentDeploymentId="d1" jarRetentionCount={5} onSelect={() => {}} />);
    expect(screen.queryByText('v6')).toBeNull();
    expect(screen.getByText(/no past deployments/i)).toBeInTheDocument();
  });

  it('caps visible rows at jarRetentionCount and shows expander', () => {
    const many = Array.from({ length: 10 }, (_, i) => ({
      ...stoppedDep, id: `d${i}`, deployedAt: `2026-04-23T10:${10 + i}:00Z`,
    }));
    render(<CheckpointsTable deployments={many} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={3} onSelect={() => {}} />);
    expect(screen.getAllByRole('row').length).toBeLessThanOrEqual(3 + 2);
    expect(screen.getByText(/show older \(7\)/i)).toBeInTheDocument();
  });

  it('shows all rows when jarRetentionCount >= total', () => {
    render(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={10} onSelect={() => {}} />);
    expect(screen.queryByText(/show older/i)).toBeNull();
  });

  it('falls back to default cap of 10 when jarRetentionCount is 0', () => {
    const fifteen = Array.from({ length: 15 }, (_, i) => ({
      ...stoppedDep, id: `d${i}`, deployedAt: `2026-04-23T10:${10 + i}:00Z`,
    }));
    render(<CheckpointsTable deployments={fifteen} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={0} onSelect={() => {}} />);
    expect(screen.getByText(/show older \(5\)/i)).toBeInTheDocument();
  });
});
  • Step 2: Run tests to verify they fail
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx

Expected: all 8 tests fail (module not found).

  • Step 3: Implement the component

CheckpointsTable.tsx:

import { useState } from 'react';
import { Badge } from '@cameleer/design-system';
import type { Deployment, AppVersion } from '../../../api/queries/admin/apps';
import { timeAgo } from '../../../utils/format-utils';
import styles from './AppDeploymentPage.module.css';

interface CheckpointsTableProps {
  deployments: Deployment[];
  versions: AppVersion[];
  currentDeploymentId: string | null;
  jarRetentionCount: number;
  onSelect: (deploymentId: string) => void;
}

const FALLBACK_CAP = 10;

export function CheckpointsTable({
  deployments, versions, currentDeploymentId, jarRetentionCount, onSelect,
}: CheckpointsTableProps) {
  const [expanded, setExpanded] = useState(false);
  const versionMap = new Map(versions.map((v) => [v.id, v]));

  const checkpoints = deployments
    .filter((d) => d.deployedConfigSnapshot && d.id !== currentDeploymentId)
    .sort((a, b) => (b.deployedAt ?? '').localeCompare(a.deployedAt ?? ''));

  if (checkpoints.length === 0) {
    return <div className={styles.checkpointEmpty}>No past deployments yet.</div>;
  }

  const cap = jarRetentionCount > 0 ? jarRetentionCount : FALLBACK_CAP;
  const visible = expanded ? checkpoints : checkpoints.slice(0, cap);
  const hidden = checkpoints.length - visible.length;

  return (
    <div className={styles.checkpointsTable}>
      <table>
        <thead>
          <tr>
            <th>Version</th>
            <th>JAR</th>
            <th>Deployed by</th>
            <th>Deployed</th>
            <th>Strategy</th>
            <th>Outcome</th>
            <th aria-label="expand"></th>
          </tr>
        </thead>
        <tbody>
          {visible.map((d) => {
            const v = versionMap.get(d.appVersionId);
            const archived = !v;
            return (
              <tr
                key={d.id}
                role="row"
                aria-label={`Deployment v${v?.version ?? '?'} ${v?.jarFilename ?? ''}`}
                className={archived ? styles.checkpointArchived : undefined}
                onClick={() => onSelect(d.id)}
                style={{ cursor: 'pointer' }}
              >
                <td><Badge label={v ? `v${v.version}` : '?'} color="auto" /></td>
                <td className={styles.jarCell}>
                  {v ? <span className={styles.jarName}>{v.jarFilename}</span>
                     : <><span className={styles.jarStrike}>JAR pruned</span>
                         <div className={styles.archivedHint}>archived  JAR pruned</div></>}
                </td>
                <td>{d.createdBy ?? <span className={styles.muted}></span>}</td>
                <td>
                  {d.deployedAt && timeAgo(d.deployedAt)}
                  <div className={styles.isoSubline}>{d.deployedAt}</div>
                </td>
                <td>
                  <span className={styles.strategyPill}>
                    {d.deploymentStrategy === 'BLUE_GREEN' ? 'blue/green' : 'rolling'}
                  </span>
                </td>
                <td>
                  <span className={`${styles.outcomePill} ${styles[`outcome-${d.status}`]}`}>
                    {d.status}
                  </span>
                </td>
                <td className={styles.chevron}></td>
              </tr>
            );
          })}
        </tbody>
      </table>
      {hidden > 0 && !expanded && (
        <button type="button" className={styles.showOlderBtn} onClick={() => setExpanded(true)}>
          Show older ({hidden})  archived, postmortem only
        </button>
      )}
    </div>
  );
}
  • Step 4: Add styles to AppDeploymentPage.module.css

Add these classes (use existing tokens from the existing .checkpointRow, etc. styles for consistency):

.checkpointsTable table { width: 100%; border-collapse: collapse; font-size: 13px; }
.checkpointsTable th {
  text-align: left; padding: 10px 12px; font-weight: 600; font-size: 11px;
  text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted);
  background: var(--bg-subtle); border-bottom: 1px solid var(--border);
}
.checkpointsTable td { padding: 12px; border-top: 1px solid var(--border-subtle); vertical-align: top; }
.checkpointsTable tbody tr:hover { background: var(--bg-hover); }
.checkpointArchived { opacity: 0.55; }
.jarCell { font-family: var(--font-mono); font-size: 12px; }
.jarStrike { text-decoration: line-through; }
.archivedHint { font-size: 11px; color: var(--amber); }
.isoSubline { font-size: 11px; color: var(--text-muted); }
.muted { color: var(--text-muted); }
.strategyPill, .outcomePill {
  display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 11px;
  background: var(--bg-subtle);
}
.outcome-STOPPED { color: var(--text-muted); }
.outcome-DEGRADED { background: var(--amber-bg); color: var(--amber); }
.chevron { color: var(--text-muted); font-size: 14px; }
.showOlderBtn {
  width: 100%; padding: 10px; background: transparent; border: 0;
  color: var(--text-muted); cursor: pointer; font-size: 12px;
}
.showOlderBtn:hover { background: var(--bg-hover); color: var(--text); }
  • Step 5: Run tests to verify they pass
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx

Expected: all 8 tests PASS.

  • Step 6: Type-check
cd ui && npm run typecheck

Expected: zero errors.

  • Step 7: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx \
        ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx \
        ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css
git commit -m "feat(ui): CheckpointsTable component (replaces row list)"

Task 10: Build CheckpointDetailDrawer — container + LogsPanel

Files:

  • Create: ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx

  • Create: ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/LogsPanel.tsx

  • Create: ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/instance-id.ts (helper to derive instance_ids from a deployment)

  • Create: ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx

  • Step 1: Write the failing tests

// CheckpointDetailDrawer.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { CheckpointDetailDrawer } from './index';
import { instanceIdsFor } from './instance-id';

describe('instanceIdsFor', () => {
  it('derives N instance_ids from replicaStates + deployment id', () => {
    expect(instanceIdsFor({
      id: 'aaa11111-2222-3333-4444-555555555555',
      replicaStates: [{ index: 0 }, { index: 1 }, { index: 2 }],
    } as any, 'prod', 'my-app')).toEqual([
      'prod-my-app-0-aaa11111',
      'prod-my-app-1-aaa11111',
      'prod-my-app-2-aaa11111',
    ]);
  });

  it('returns empty array when no replicas', () => {
    expect(instanceIdsFor({ id: 'x', replicaStates: [] } as any, 'e', 'a')).toEqual([]);
  });
});

describe('CheckpointDetailDrawer', () => {
  const baseDep: any = {
    id: 'aaa11111-2222-3333-4444-555555555555',
    appVersionId: 'v6id', status: 'STOPPED', deploymentStrategy: 'BLUE_GREEN',
    replicaStates: [{ index: 0 }], createdBy: 'alice',
    deployedAt: '2026-04-23T10:35:00Z', stoppedAt: '2026-04-23T10:52:00Z',
    deployedConfigSnapshot: { jarVersionId: 'v6id', agentConfig: null, containerConfig: {}, sensitiveKeys: null },
  };
  const v: any = { id: 'v6id', version: 6, jarFilename: 'my-app-1.2.3.jar' };

  function renderDrawer(props: Partial<any> = {}) {
    const qc = new QueryClient();
    return render(
      <QueryClientProvider client={qc}>
        <CheckpointDetailDrawer
          open
          onClose={() => {}}
          deployment={baseDep}
          version={v}
          appSlug="my-app"
          envSlug="prod"
          onRestore={() => {}}
          {...props}
        />
      </QueryClientProvider>
    );
  }

  it('renders header with version + jar + outcome', () => {
    renderDrawer();
    expect(screen.getByText(/v6/)).toBeInTheDocument();
    expect(screen.getByText(/my-app-1\.2\.3\.jar/)).toBeInTheDocument();
    expect(screen.getByText(/STOPPED/)).toBeInTheDocument();
  });

  it('renders meta line with createdBy', () => {
    renderDrawer();
    expect(screen.getByText(/alice/)).toBeInTheDocument();
  });

  it('Logs tab is selected by default', () => {
    renderDrawer();
    expect(screen.getByRole('tab', { name: /logs/i, selected: true })).toBeInTheDocument();
  });

  it('disables Restore when JAR is pruned', () => {
    renderDrawer({ version: undefined });
    expect(screen.getByRole('button', { name: /restore/i })).toBeDisabled();
  });
});
  • Step 2: Run tests to verify they fail
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer

Expected: all tests fail (modules not found).

  • Step 3: Implement instance-id.ts
import type { Deployment } from '../../../../api/queries/admin/apps';

export function instanceIdsFor(
  deployment: Pick<Deployment, 'id' | 'replicaStates'>,
  envSlug: string,
  appSlug: string,
): string[] {
  const generation = deployment.id.replace(/-/g, '').slice(0, 8);
  return deployment.replicaStates.map((r) =>
    `${envSlug}-${appSlug}-${r.index}-${generation}`
  );
}

Note: confirm the generation format matches the backend. Per docker-orchestration.md: "generation is the first 8 characters of the deployment UUID". UUIDs are typically rendered with hyphens (e.g. aaa11111-2222-...); the first 8 chars (no hyphens) are the leading 8 hex chars. Adjust if the backend uses with-hyphens slicing.

  • Step 4: Implement LogsPanel.tsx
import { useMemo, useState } from 'react';
import { LogViewer } from '@cameleer/design-system';
import { useInfiniteApplicationLogs } from '../../../../api/queries/logs';
import type { Deployment } from '../../../../api/queries/admin/apps';
import { instanceIdsFor } from './instance-id';

interface Props {
  deployment: Deployment;
  appSlug: string;
  envSlug: string;
}

export function LogsPanel({ deployment, appSlug, envSlug }: Props) {
  const allInstanceIds = useMemo(
    () => instanceIdsFor(deployment, envSlug, appSlug),
    [deployment, envSlug, appSlug]
  );

  const [replicaFilter, setReplicaFilter] = useState<'all' | number>('all');
  const filteredInstanceIds = replicaFilter === 'all'
    ? allInstanceIds
    : allInstanceIds.filter((_, i) => i === replicaFilter);

  const logs = useInfiniteApplicationLogs({
    appSlug, envSlug, instanceIds: filteredInstanceIds,
  });

  return (
    <div>
      <div /* filter bar */>
        <select
          value={String(replicaFilter)}
          onChange={(e) =>
            setReplicaFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
          }
        >
          <option value="all">all ({deployment.replicaStates.length})</option>
          {deployment.replicaStates.map((r) => (
            <option key={r.index} value={r.index}>{r.index}</option>
          ))}
        </select>
      </div>
      <LogViewer entries={logs.items} loading={logs.isLoading}
        onLoadMore={logs.hasMore ? logs.loadMore : undefined} />
    </div>
  );
}
  • Step 5: Implement index.tsx (drawer container)
import { useState } from 'react';
import { SideDrawer } from '../../../../components/SideDrawer';
import { Tabs, Button, Badge } from '@cameleer/design-system';
import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps';
import { LogsPanel } from './LogsPanel';
import { ConfigPanel } from './ConfigPanel';

interface Props {
  open: boolean;
  onClose: () => void;
  deployment: Deployment;
  version?: AppVersion;
  appSlug: string;
  envSlug: string;
  onRestore: (deploymentId: string) => void;
}

export function CheckpointDetailDrawer({
  open, onClose, deployment, version, appSlug, envSlug, onRestore,
}: Props) {
  const [tab, setTab] = useState<'logs' | 'config'>('logs');
  const archived = !version;

  const title = (
    <div>
      <Badge label={version ? `v${version.version}` : '?'} color="auto" />{' '}
      <span style={{ fontFamily: 'var(--font-mono)' }}>
        {version?.jarFilename ?? 'JAR pruned'}
      </span>{' '}
      <span>{deployment.status}</span>
    </div>
  );

  const footer = (
    <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
      <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
        Restoring hydrates the form  you'll still need to Redeploy.
      </span>
      <Button
        variant="primary"
        disabled={archived}
        title={archived ? 'JAR was pruned by the environment retention policy' : undefined}
        onClick={() => { onRestore(deployment.id); onClose(); }}
      >
        Restore this checkpoint
      </Button>
    </div>
  );

  return (
    <SideDrawer open={open} onClose={onClose} title={title} size="lg" footer={footer}>
      <div /* meta line */>
        Deployed by <b>{deployment.createdBy ?? ''}</b> ·{' '}
        {deployment.deployedAt} · Strategy: {deployment.deploymentStrategy} ·{' '}
        {deployment.replicaStates.length} replicas
      </div>
      <Tabs
        active={tab}
        onChange={(t) => setTab(t as 'logs' | 'config')}
        tabs={[
          { id: 'logs', label: 'Logs' },
          { id: 'config', label: 'Config' },
        ]}
      />
      {tab === 'logs' && (
        <LogsPanel deployment={deployment} appSlug={appSlug} envSlug={envSlug} />
      )}
      {tab === 'config' && (
        <ConfigPanel deployment={deployment} appSlug={appSlug} envSlug={envSlug}
          archived={archived} />
      )}
    </SideDrawer>
  );
}

(Stub ConfigPanel for now to satisfy import — will be implemented in Task 11.)

// ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.tsx
import type { Deployment } from '../../../../api/queries/admin/apps';
interface Props {
  deployment: Deployment;
  appSlug: string;
  envSlug: string;
  archived: boolean;
}
export function ConfigPanel(_: Props) {
  return <div>Config panel  implemented in next task</div>;
}
  • Step 6: Run tests to verify they pass
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer

Expected: all PASS.

  • Step 7: Type-check
cd ui && npm run typecheck

Expected: zero errors.

  • Step 8: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/
git commit -m "feat(ui): CheckpointDetailDrawer container + LogsPanel"

Task 11: Implement ConfigPanel with Snapshot/Diff modes

Files:

  • Modify: ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.tsx

  • Create: ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/diff.ts (deep-equal helper)

  • Create: ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/diff.test.ts

  • Create: ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.test.tsx

  • Step 1: Write the failing diff helper test

// diff.test.ts
import { describe, it, expect } from 'vitest';
import { fieldDiff } from './diff';

describe('fieldDiff', () => {
  it('returns empty list for equal objects', () => {
    expect(fieldDiff({ a: 1, b: 'x' }, { a: 1, b: 'x' })).toEqual([]);
  });

  it('detects changed values', () => {
    expect(fieldDiff({ a: 1 }, { a: 2 })).toEqual([{ path: 'a', oldValue: 1, newValue: 2 }]);
  });

  it('detects added keys', () => {
    expect(fieldDiff({}, { a: 1 })).toEqual([{ path: 'a', oldValue: undefined, newValue: 1 }]);
  });

  it('detects removed keys', () => {
    expect(fieldDiff({ a: 1 }, {})).toEqual([{ path: 'a', oldValue: 1, newValue: undefined }]);
  });

  it('walks nested objects', () => {
    const diff = fieldDiff({ resources: { mem: 512 } }, { resources: { mem: 1024 } });
    expect(diff).toEqual([{ path: 'resources.mem', oldValue: 512, newValue: 1024 }]);
  });

  it('compares arrays by position', () => {
    const diff = fieldDiff({ keys: ['a', 'b'] }, { keys: ['a', 'c'] });
    expect(diff).toEqual([{ path: 'keys[1]', oldValue: 'b', newValue: 'c' }]);
  });
});
  • Step 2: Run test to verify it fails
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/diff.test.ts

Expected: all 6 tests fail.

  • Step 3: Implement diff.ts
export interface FieldDiff {
  path: string;
  oldValue: unknown;
  newValue: unknown;
}

function isPlainObject(v: unknown): v is Record<string, unknown> {
  return typeof v === 'object' && v !== null && !Array.isArray(v);
}

export function fieldDiff(
  a: unknown, b: unknown, path = ''
): FieldDiff[] {
  if (Object.is(a, b)) return [];

  if (isPlainObject(a) && isPlainObject(b)) {
    const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
    const out: FieldDiff[] = [];
    for (const k of keys) {
      const sub = path ? `${path}.${k}` : k;
      out.push(...fieldDiff(a[k], b[k], sub));
    }
    return out;
  }

  if (Array.isArray(a) && Array.isArray(b)) {
    const len = Math.max(a.length, b.length);
    const out: FieldDiff[] = [];
    for (let i = 0; i < len; i++) {
      out.push(...fieldDiff(a[i], b[i], `${path}[${i}]`));
    }
    return out;
  }

  return [{ path: path || '(root)', oldValue: a, newValue: b }];
}
  • Step 4: Run test to verify it passes
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/diff.test.ts

Expected: PASS.

  • Step 5: Write ConfigPanel.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ConfigPanel } from './ConfigPanel';

const dep: any = {
  deployedConfigSnapshot: {
    jarVersionId: 'v6', agentConfig: null,
    containerConfig: { resources: { memoryLimitMb: 512 } },
    sensitiveKeys: null,
  },
};

function wrap(node: React.ReactNode) {
  const qc = new QueryClient();
  return <QueryClientProvider client={qc}>{node}</QueryClientProvider>;
}

describe('ConfigPanel', () => {
  it('renders the 5 read-only sub-tabs in Snapshot mode', () => {
    render(wrap(<ConfigPanel deployment={dep} appSlug="a" envSlug="e" archived={false} />));
    ['Resources', 'Monitoring', 'Variables', 'Sensitive Keys', 'Deployment'].forEach(t =>
      expect(screen.getByRole('tab', { name: new RegExp(t, 'i') })).toBeInTheDocument()
    );
  });

  it('hides the Snapshot/Diff toggle when archived', () => {
    render(wrap(<ConfigPanel deployment={dep} appSlug="a" envSlug="e" archived />));
    expect(screen.queryByRole('button', { name: /diff/i })).toBeNull();
  });

  it('shows the toggle when not archived', () => {
    render(wrap(<ConfigPanel deployment={dep} appSlug="a" envSlug="e" archived={false} />));
    expect(screen.getByText(/snapshot/i)).toBeInTheDocument();
    expect(screen.getByText(/diff vs current/i)).toBeInTheDocument();
  });
});
  • Step 6: Run test to verify it fails
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.test.tsx

Expected: tests fail (stub still in place).

  • Step 7: Implement ConfigPanel.tsx
import { useMemo, useState } from 'react';
import { SegmentedTabs, Tabs } from '@cameleer/design-system';
import { useApplicationConfig } from '../../../../api/queries/admin/applicationConfig';
// (verify the exact hook name — adjust to whatever the live form uses)
import type { Deployment } from '../../../../api/queries/admin/apps';
import { fieldDiff, type FieldDiff } from './diff';
import { MonitoringTab } from '../ConfigTabs/MonitoringTab';
import { ResourcesTab } from '../ConfigTabs/ResourcesTab';
import { VariablesTab } from '../ConfigTabs/VariablesTab';
import { SensitiveKeysTab } from '../ConfigTabs/SensitiveKeysTab';
import { DeploymentTabConfig } from '../ConfigTabs/DeploymentTab'; // verify name

interface Props {
  deployment: Deployment;
  appSlug: string;
  envSlug: string;
  archived: boolean;
}

type Mode = 'snapshot' | 'diff';
type SubTab = 'monitoring' | 'resources' | 'variables' | 'sensitive' | 'deployment';

export function ConfigPanel({ deployment, appSlug, envSlug, archived }: Props) {
  const [mode, setMode] = useState<Mode>('snapshot');
  const [subTab, setSubTab] = useState<SubTab>('resources');

  const snapshot = deployment.deployedConfigSnapshot;
  const liveQuery = useApplicationConfig(appSlug, envSlug);
  const live = liveQuery.data;

  const diff: FieldDiff[] = useMemo(
    () => mode === 'diff' && snapshot && live ? fieldDiff(snapshot, live) : [],
    [mode, snapshot, live]
  );

  const countByTab = useMemo(() => {
    const map: Record<SubTab, number> = {
      monitoring: 0, resources: 0, variables: 0, sensitive: 0, deployment: 0,
    };
    for (const d of diff) {
      // Map diff path prefixes to sub-tabs. Prefixes derive from the sub-tab field
      // structure — adjust based on how the backend snapshot is shaped.
      if (d.path.startsWith('containerConfig.resources') ||
          d.path.startsWith('containerConfig.memory') ||
          d.path.startsWith('containerConfig.cpu')) map.resources++;
      else if (d.path.startsWith('containerConfig.customEnvVars') ||
               d.path.startsWith('agentConfig.variables')) map.variables++;
      else if (d.path.startsWith('sensitiveKeys')) map.sensitive++;
      else if (d.path.startsWith('containerConfig.deploymentStrategy') ||
               d.path.startsWith('containerConfig.replicas')) map.deployment++;
      else map.monitoring++;
    }
    return map;
  }, [diff]);

  return (
    <div>
      {!archived && (
        <SegmentedTabs
          tabs={[{ id: 'snapshot', label: 'Snapshot' },
                 { id: 'diff', label: `Diff vs current${diff.length ? ` (${diff.length})` : ''}` }]}
          active={mode}
          onChange={(m) => setMode(m as Mode)}
        />
      )}

      <Tabs
        active={subTab}
        onChange={(t) => setSubTab(t as SubTab)}
        tabs={[
          { id: 'resources', label: `Resources${countByTab.resources ? ` (${countByTab.resources})` : ''}` },
          { id: 'monitoring', label: `Monitoring${countByTab.monitoring ? ` (${countByTab.monitoring})` : ''}` },
          { id: 'variables', label: `Variables${countByTab.variables ? ` (${countByTab.variables})` : ''}` },
          { id: 'sensitive', label: `Sensitive Keys${countByTab.sensitive ? ` (${countByTab.sensitive})` : ''}` },
          { id: 'deployment', label: `Deployment${countByTab.deployment ? ` (${countByTab.deployment})` : ''}` },
        ]}
      />

      {mode === 'snapshot' && (
        <>
          {subTab === 'resources' && <ResourcesTab readOnly /* + snapshot-derived props */ />}
          {subTab === 'monitoring' && <MonitoringTab readOnly /* + snapshot-derived props */ />}
          {subTab === 'variables' && <VariablesTab readOnly /* + snapshot-derived props */ />}
          {subTab === 'sensitive' && <SensitiveKeysTab readOnly /* + snapshot-derived props */ />}
          {subTab === 'deployment' && <DeploymentTabConfig readOnly /* + snapshot-derived props */ />}
        </>
      )}

      {mode === 'diff' && (
        <DiffView diffs={diffsForTab(diff, subTab)} />
      )}
    </div>
  );
}

function diffsForTab(all: FieldDiff[], tab: SubTab): FieldDiff[] {
  return all.filter((d) => {
    if (tab === 'resources') {
      return d.path.startsWith('containerConfig.resources') ||
             d.path.startsWith('containerConfig.memory') ||
             d.path.startsWith('containerConfig.cpu');
    }
    if (tab === 'variables') {
      return d.path.startsWith('containerConfig.customEnvVars') ||
             d.path.startsWith('agentConfig.variables');
    }
    if (tab === 'sensitive') {
      return d.path.startsWith('sensitiveKeys');
    }
    if (tab === 'deployment') {
      return d.path.startsWith('containerConfig.deploymentStrategy') ||
             d.path.startsWith('containerConfig.replicas');
    }
    // monitoring catches everything else
    return !(d.path.startsWith('containerConfig.resources') ||
             d.path.startsWith('containerConfig.memory') ||
             d.path.startsWith('containerConfig.cpu') ||
             d.path.startsWith('containerConfig.customEnvVars') ||
             d.path.startsWith('agentConfig.variables') ||
             d.path.startsWith('sensitiveKeys') ||
             d.path.startsWith('containerConfig.deploymentStrategy') ||
             d.path.startsWith('containerConfig.replicas'));
  });
}

function DiffView({ diffs }: { diffs: FieldDiff[] }) {
  if (diffs.length === 0) {
    return <div style={{ color: 'var(--text-muted)', padding: 16 }}>
      No differences in this section.
    </div>;
  }
  return (
    <div style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>
      {diffs.map((d) => (
        <div key={d.path} style={{ marginBottom: 8 }}>
          <div style={{ color: 'var(--text-muted)' }}>{d.path}</div>
          <div style={{ background: 'var(--red-bg)', borderLeft: '2px solid var(--red)', padding: '2px 6px' }}>
            - {JSON.stringify(d.oldValue)}
          </div>
          <div style={{ background: 'var(--green-bg)', borderLeft: '2px solid var(--green)', padding: '2px 6px' }}>
            + {JSON.stringify(d.newValue)}
          </div>
        </div>
      ))}
    </div>
  );
}

The exact prop signature each ConfigTab needs to receive depends on how the live form passes them today. Open the existing ConfigTabs/*.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
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.test.tsx

Expected: all PASS.

  • Step 9: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.tsx \
        ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/diff.ts \
        ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/diff.test.ts \
        ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.test.tsx
git commit -m "feat(ui): ConfigPanel with snapshot+diff modes"

Task 12: Wire CheckpointsTable + Drawer into IdentitySection

Files:

  • Modify: ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx

  • Delete: ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx

  • Step 1: Read IdentitySection.tsx to find the existing Checkpoints integration

Locate where <Checkpoints ... /> is rendered. Note the props it receives.

  • Step 2: Replace the import + rendering
// At top of IdentitySection.tsx
import { useState } from 'react';
import { CheckpointsTable } from './CheckpointsTable';
import { CheckpointDetailDrawer } from './CheckpointDetailDrawer';
import { useEnvironments } from '../../../api/queries/admin/environments';

// In the component body
const [selectedCheckpointId, setSelectedCheckpointId] = useState<string | null>(null);
const { data: envs } = useEnvironments();
const env = envs?.find((e) => e.slug === envSlug);
const jarRetentionCount = env?.jarRetentionCount ?? 0;
const versionMap = new Map(versions.map((v) => [v.id, v]));
const selectedDep = deployments.find((d) => d.id === selectedCheckpointId) ?? null;
const selectedVersion = selectedDep ? versionMap.get(selectedDep.appVersionId) : undefined;

// Where <Checkpoints ... /> used to be:
<CheckpointsTable
  deployments={deployments}
  versions={versions}
  currentDeploymentId={currentDeploymentId}
  jarRetentionCount={jarRetentionCount}
  onSelect={setSelectedCheckpointId}
/>
{selectedDep && (
  <CheckpointDetailDrawer
    open
    onClose={() => setSelectedCheckpointId(null)}
    deployment={selectedDep}
    version={selectedVersion}
    appSlug={appSlug}
    envSlug={envSlug}
    onRestore={onRestore}
  />
)}
  • Step 3: Delete the old Checkpoints component
rm ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx
  • Step 4: Type-check
cd ui && npm run typecheck

Expected: zero errors. (If the env hook name differs, adjust the import path.)

  • Step 5: Run all UI tests
cd ui && npm run test

Expected: all PASS.

  • Step 6: Manual smoke test

Start backend + UI dev:

# terminal 1: backend
java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar
# terminal 2: UI
cd ui && npm run dev

Browse to the deployment page for an app with at least 2 historical deployments. Verify:

  • Table renders with all expected columns

  • Row click opens the drawer

  • Logs tab loads logs for the deployment's instance_ids

  • Config tab renders read-only sub-tabs

  • Diff toggle (when JAR available) shows differences vs current live config

  • Restore button hydrates the form (existing behavior)

  • Pruned-JAR row is dimmed; Restore disabled in drawer; Diff toggle hidden

  • Step 7: Commit

git add ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx
git rm ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx
git commit -m "feat(ui): wire CheckpointsTable + Drawer into IdentitySection (delete old Checkpoints)"

Phase 5 — Polish

Task 13: Surface DEPLOYMENT category in Admin Audit page filter

Files:

  • Modify: ui/src/pages/Admin/AuditLogPage.tsx (verify path; the rules note it exists)

  • Step 1: Locate the category filter dropdown

Open the file. Find the <Select> (or <select>) bound to the category filter. It will have an array of options like ['INFRA', 'AUTH', 'USER_MGMT', ...].

  • Step 2: Add 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
git add ui/src/pages/Admin/AuditLogPage.tsx
git commit -m "ui(audit): surface DEPLOYMENT category in admin filter"

Task 14: Update .claude/rules/ documentation

Files:

  • Modify: .claude/rules/app-classes.md (DeploymentController audit calls + LogQueryController instanceIds param)

  • Modify: .claude/rules/ui.md (CheckpointsTable + SideDrawer pattern)

  • Modify: .claude/rules/core-classes.md (AuditCategory.DEPLOYMENT, Deployment.createdBy)

  • Step 1: Update app-classes.md

In the DeploymentController bullet, replace the existing description with one that mentions audit:

- `DeploymentController``/api/v1/environments/{envSlug}/apps/{appSlug}/deployments`. GET list / POST create (body `{ appVersionId }`, audited as `deploy_app`) / POST `{id}/stop` (audited as `stop_deployment`) / POST `{id}/promote` (audited as `promote_deployment`) / GET `{id}/logs`. All lifecycle ops audited under `AuditCategory.DEPLOYMENT` with the acting user resolved via the `user:` strip convention; FAILURE branches also write audit rows.

In the LogQueryController bullet, add to the filter list:

- `LogQueryController` — GET `/api/v1/environments/{envSlug}/logs` (filters: source (multi, comma-split), level (multi, comma-split), application, agentId, exchangeId, logger, q, instanceIds (multi, comma-split — `WHERE instance_id IN (...)` for per-deployment scoping), time range; sort asc/desc). ...
  • Step 2: Update core-classes.md

In the AuditCategory bullet, add DEPLOYMENT to the enum list. In the Deployment record bullet, append , createdBy (String user_id).

  • Step 3: Update ui.md

Under "Deployments" / AppDeploymentPage, add or update the Checkpoints description:

- Checkpoints render as a `CheckpointsTable` (DataTable-style) with columns: Version · JAR · Deployed by · Deployed · Strategy · Outcome · . Row click opens `CheckpointDetailDrawer` (project-local `SideDrawer` primitive — `ui/src/components/SideDrawer.tsx`). Drawer has Logs and Config tabs; Config has Snapshot / Diff vs current modes. Restore lives in the drawer footer (forces review). Visible row cap = `Environment.jarRetentionCount` (default 10 if 0); older rows accessible via "Show older (N)" expander.

Under shared components, add:

- `ui/src/components/SideDrawer.tsx` — project-local right-slide drawer (DS has Modal but no Drawer). Portal-based, ESC + transparent-backdrop click closes. `size: 'md' | 'lg' | 'xl'`. Used by CheckpointDetailDrawer; promote to DS if a second consumer appears.
  • Step 4: Commit
git add .claude/rules/app-classes.md .claude/rules/ui.md .claude/rules/core-classes.md
git commit -m "docs(rules): deployment audit + checkpoints table + SideDrawer"

Task 15: Final integration smoke test

  • Step 1: Full backend build with all ITs
mvn clean verify

Expected: BUILD SUCCESS with all unit tests + ITs green.

  • Step 2: Full UI build + tests
cd ui && npm run typecheck && npm run test && npm run build

Expected: zero errors.

  • Step 3: End-to-end manual verification

Start backend + UI. As an OPERATOR user "alice":

  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.