diff --git a/docs/superpowers/plans/2026-04-22-app-deployment-page.md b/docs/superpowers/plans/2026-04-22-app-deployment-page.md new file mode 100644 index 00000000..8f67b3aa --- /dev/null +++ b/docs/superpowers/plans/2026-04-22-app-deployment-page.md @@ -0,0 +1,3319 @@ +# Unified App Deployment Page — 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 `/apps/new` (`CreateAppView`) and `/apps/:slug` (`AppDetailView`) with a unified deployment page that supports staged saves, dirty detection against the last successful deploy snapshot, checkpoint restore, and a persistent Deployment tab with progress + logs. + +**Architecture:** Single SPA page component (`AppDeploymentPage`) renders both net-new and deployed modes, distinguished by app existence. Dirty-state comes from comparing the app's current DB config to a new `deployments.deployed_config_snapshot` JSONB column captured on every successful deploy. Agent config writes gain an `?apply=staged|live` flag so deployment-page saves no longer auto-push to running agents (Dashboard/Runtime keep live-push behavior). + +**Tech Stack:** Spring Boot 3.4.3 + Postgres (Flyway) + ClickHouse backend · React 18 + TanStack Query + React Router v6 + `@cameleer/design-system` UI · Vitest + JUnit integration tests. + +**Reference spec:** `docs/superpowers/specs/2026-04-22-app-deployment-page-design.md` + +--- + +## File Structure + +### Backend (new / modified) + +- **Create:** `cameleer-server-app/src/main/resources/db/migration/V3__deployment_config_snapshot.sql` — adds `deployed_config_snapshot JSONB` on `deployments`. +- **Modify:** `cameleer-server-app/src/main/java/com/cameleer/server/core/runtime/Deployment.java` (or the record defining the `Deployment` model) — add `deployedConfigSnapshot` field. +- **Modify:** `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java` — read/write snapshot column. +- **Modify:** `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java` — populate snapshot on successful completion. +- **Create:** `cameleer-server-app/src/main/java/com/cameleer/server/core/runtime/DeploymentConfigSnapshot.java` — record carrying `{jarVersionId, agentConfig, containerConfig}`. +- **Modify:** `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ApplicationConfigController.java` — add `?apply=staged|live` param (default `live`) on PUT. +- **Modify:** `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppController.java` — add `GET /apps/{appSlug}/dirty-state`. +- **Create:** `cameleer-server-app/src/main/java/com/cameleer/server/app/dto/DirtyStateResponse.java` — DTO `{dirty: boolean, lastSuccessfulDeploymentId: String|null, differences: List}`. +- **Create:** `cameleer-server-app/src/main/java/com/cameleer/server/core/runtime/DirtyStateCalculator.java` — pure service comparing current config to snapshot, producing differences. +- **Modify:** `cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ApplicationConfigControllerIT.java` — add staged/live tests. +- **Create:** `cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AppDirtyStateIT.java` — dirty-state endpoint tests. +- **Create:** `cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java` — snapshot-on-success tests. +- **Create:** `cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/DirtyStateCalculatorTest.java` — pure unit tests. + +### UI (new / modified / deleted) + +- **Create:** `ui/src/pages/AppsTab/AppDeploymentPage/index.tsx` — main page component (net-new and deployed modes). +- **Create:** `ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx` — always-visible Identity & Artifact section. +- **Create:** `ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx` — past successful deployments disclosure. +- **Create:** `ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx` — Save/Redeploy state-machine button. +- **Create:** `ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/MonitoringTab.tsx` +- **Create:** `ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/ResourcesTab.tsx` +- **Create:** `ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/VariablesTab.tsx` +- **Create:** `ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/SensitiveKeysTab.tsx` +- **Create:** `ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/TracesTapsTab.tsx` — with live banner. +- **Create:** `ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/RouteRecordingTab.tsx` — with live banner. +- **Create:** `ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/LiveBanner.tsx` — shared amber banner component. +- **Create:** `ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/DeploymentTab.tsx` +- **Create:** `ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/StatusCard.tsx` +- **Create:** `ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/HistoryDisclosure.tsx` +- **Create:** `ui/src/pages/AppsTab/AppDeploymentPage/hooks/useDeploymentPageState.ts` — orchestrating form state hook. +- **Create:** `ui/src/pages/AppsTab/AppDeploymentPage/hooks/useDirtyState.ts` — wrapper around dirty-state endpoint. +- **Create:** `ui/src/pages/AppsTab/AppDeploymentPage/hooks/useUnsavedChangesBlocker.ts` — router blocker + confirm dialog. +- **Create:** `ui/src/pages/AppsTab/AppDeploymentPage/utils/deriveAppName.ts` — filename → name pure function. +- **Create:** `ui/src/pages/AppsTab/AppDeploymentPage/utils/deriveAppName.test.ts` +- **Create:** `ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css` +- **Modify:** `ui/src/api/queries/admin/apps.ts` — add `useDirtyState` hook; change `useUpdateApplicationConfig` to accept `apply` param. +- **Modify:** `ui/src/api/queries/commands.ts` — same staged flag plumbing if `useUpdateApplicationConfig` lives here. +- **Modify:** `ui/src/router.tsx` — route `/apps/new` and `/apps/:appId` both to `AppDeploymentPage`. +- **Modify:** `ui/src/pages/AppsTab/AppsTab.tsx` — reduce to only `AppListView`. +- **Modify:** `ui/src/components/StartupLogPanel.tsx` — drop fixed 300px, accept `className` so parent can flex-grow. +- **Delete:** all of `CreateAppView`, `AppDetailView`, `OverviewSubTab`, `ConfigSubTab`, `VersionRow` in `AppsTab.tsx` (keep only `AppListView`). +- **Modify:** `.claude/rules/ui.md` — rewrite Deployments bullet. +- **Modify:** `.claude/rules/app-classes.md` — note `?apply=staged|live` on `ApplicationConfigController` and new `GET /apps/{appSlug}/dirty-state` on `AppController`. +- **Regenerate:** `ui/src/api/openapi.json`, `ui/src/api/schema.d.ts`. + +--- + +## Phase 1 — Backend: deployment config snapshot column + +### Task 1.1: Flyway V3 migration + +**Files:** +- Create: `cameleer-server-app/src/main/resources/db/migration/V3__deployment_config_snapshot.sql` + +- [ ] **Step 1: Inspect current V2 to match style** + +Read `cameleer-server-app/src/main/resources/db/migration/V2__add_environment_color.sql` — format has a header comment, single `ALTER TABLE`. + +- [ ] **Step 2: Write V3 migration** + +```sql +-- V3: per-deployment config snapshot for "last known good" + dirty detection +-- Captures {jarVersionId, agentConfig, containerConfig} at the moment a +-- deployment transitions to RUNNING. Historical rows are NULL; dirty detection +-- treats NULL as "everything dirty" and the next successful Redeploy populates it. + +ALTER TABLE deployments + ADD COLUMN deployed_config_snapshot JSONB; +``` + +- [ ] **Step 3: Verify Flyway picks it up on a clean start** + +Run (in a dev shell — the local Postgres must already be up per your local-services policy): + +```bash +mvn -pl cameleer-server-app -am clean verify -Dtest=SchemaBootstrapIT -DfailIfNoTests=false +``` + +Expected: `SchemaBootstrapIT` passes — it validates V1 + V2 + V3 apply cleanly. + +- [ ] **Step 4: Commit** + +```bash +git add cameleer-server-app/src/main/resources/db/migration/V3__deployment_config_snapshot.sql +git commit -m "db(deploy): add deployments.deployed_config_snapshot column (V3)" +``` + +### Task 1.2: `DeploymentConfigSnapshot` record + +**Files:** +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentConfigSnapshot.java` + +- [ ] **Step 1: Write the record** + +```java +package com.cameleer.server.core.runtime; + +import com.cameleer.common.model.ApplicationConfig; + +import java.util.Map; + +/** + * Snapshot of the config that was deployed, captured at the moment a deployment + * transitions to RUNNING. Used for "last known good" restore (checkpoints) and + * for dirty-state detection on the deployment page. + * + *

This is persisted as JSONB in {@code deployments.deployed_config_snapshot}.

+ */ +public record DeploymentConfigSnapshot( + String jarVersionId, + ApplicationConfig agentConfig, + Map containerConfig +) { +} +``` + +- [ ] **Step 2: Compile** + +Run: + +```bash +mvn -pl cameleer-server-core -am compile +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 3: Commit** + +```bash +git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentConfigSnapshot.java +git commit -m "core(deploy): add DeploymentConfigSnapshot record" +``` + +### Task 1.3: `Deployment` model carries the snapshot field + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Deployment.java` (exact path — confirm during implementation; may live in `cameleer-server-app`) + +- [ ] **Step 1: Locate the Deployment record** + +Run: + +```bash +grep -rln "record Deployment\b\|class Deployment\b" cameleer-server-core/ cameleer-server-app/ +``` + +Expected: finds one definition (record or class). The subsequent step adapts to whichever shape it is. + +- [ ] **Step 2: Add the field** + +If `Deployment` is a record, append `DeploymentConfigSnapshot deployedConfigSnapshot` to the record header. If it is a class, add a `private DeploymentConfigSnapshot deployedConfigSnapshot;` field with getter/setter in the project's existing style. + +For a record, the header becomes: + +```java +public record Deployment( + UUID id, + UUID appId, + UUID appVersionId, + UUID environmentId, + DeploymentStatus status, + // ... existing fields unchanged ... + DeploymentConfigSnapshot deployedConfigSnapshot +) { } +``` + +- [ ] **Step 3: Compile — existing callers will flag** + +Run: + +```bash +mvn -pl cameleer-server-core -am compile +``` + +Expected: failures in callers that construct `Deployment` with positional args (repository mappers, tests). Note each error site. + +- [ ] **Step 4: Fix all call sites** + +For every compile error, pass `null` as the new last argument. The repository layer (Task 1.4) will be updated to read the real value; tests that don't care keep `null`. + +- [ ] **Step 5: Compile again** + +Run: + +```bash +mvn -pl cameleer-server-core -am compile +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "core(deploy): add deployedConfigSnapshot field to Deployment model" +``` + +### Task 1.4: `PostgresDeploymentRepository` reads/writes snapshot + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/storage/PostgresDeploymentRepositoryIT.java` (existing or new) + +- [ ] **Step 1: Write failing IT — store and retrieve a snapshot** + +Add to `PostgresDeploymentRepositoryIT` (create if missing, mirroring existing repository IT style): + +```java +@Test +void deployedConfigSnapshot_roundtrips() { + // given — insert a deployment with a snapshot + ApplicationConfig agent = new ApplicationConfig(); + agent.setApplication("app-it"); + agent.setEnvironment("staging"); + agent.setVersion(3); + agent.setSamplingRate(0.5); + + DeploymentConfigSnapshot snapshot = new DeploymentConfigSnapshot( + "version-abc", + agent, + Map.of("memoryLimitMb", 1024, "replicas", 2) + ); + + Deployment input = seedDeployment() // test helper that fills required fields + .withDeployedConfigSnapshot(snapshot); + + repository.save(input); + + // when — load it back + Deployment loaded = repository.findById(input.id()).orElseThrow(); + + // then + assertThat(loaded.deployedConfigSnapshot()).isNotNull(); + assertThat(loaded.deployedConfigSnapshot().jarVersionId()).isEqualTo("version-abc"); + assertThat(loaded.deployedConfigSnapshot().agentConfig().getSamplingRate()).isEqualTo(0.5); + assertThat(loaded.deployedConfigSnapshot().containerConfig()).containsEntry("memoryLimitMb", 1024); +} +``` + +Run it: + +```bash +mvn -pl cameleer-server-app test -Dtest=PostgresDeploymentRepositoryIT#deployedConfigSnapshot_roundtrips +``` + +Expected: FAIL — snapshot column is not read/written. + +- [ ] **Step 2: Update the repository mapper** + +Locate the `RowMapper` and add snapshot parsing: + +```java +// Inside the RowMapper +String snapshotJson = rs.getString("deployed_config_snapshot"); +DeploymentConfigSnapshot snapshot = null; +if (snapshotJson != null) { + try { + snapshot = objectMapper.readValue(snapshotJson, DeploymentConfigSnapshot.class); + } catch (Exception e) { + log.warn("Failed to parse deployed_config_snapshot for deployment {}: {}", id, e.getMessage()); + } +} + +return new Deployment( + // ... existing fields ... + snapshot +); +``` + +Update the INSERT/UPDATE SQL: + +```java +// INSERT: add column + placeholder +// Existing: INSERT INTO deployments (id, app_id, ..., replica_states) VALUES (?, ?, ..., ?::jsonb) +// After: INSERT INTO deployments (id, app_id, ..., replica_states, deployed_config_snapshot) VALUES (?, ?, ..., ?::jsonb, ?::jsonb) + +// Serialize snapshot to JSON (null -> null) +String snapshotJson = d.deployedConfigSnapshot() != null + ? objectMapper.writeValueAsString(d.deployedConfigSnapshot()) + : null; +ps.setObject(nextIdx++, snapshotJson); +``` + +Mirror the same change in the UPDATE statement. + +- [ ] **Step 3: Re-run the IT** + +Run: + +```bash +mvn -pl cameleer-server-app test -Dtest=PostgresDeploymentRepositoryIT#deployedConfigSnapshot_roundtrips +``` + +Expected: PASS. + +- [ ] **Step 4: Run full repository IT suite to confirm no regressions** + +Run: + +```bash +mvn -pl cameleer-server-app test -Dtest=PostgresDeploymentRepositoryIT +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java \ + cameleer-server-app/src/test/java/com/cameleer/server/app/storage/PostgresDeploymentRepositoryIT.java +git commit -m "storage(deploy): persist deployed_config_snapshot as JSONB" +``` + +### Task 1.5: `DeploymentExecutor` writes snapshot on successful COMPLETE + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java` + +- [ ] **Step 1: Read DeploymentExecutor to locate the COMPLETE transition** + +Run: + +```bash +grep -n "COMPLETE\|RUNNING\|deployedAt" cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java | head -30 +``` + +Identify the method that transitions the deployment to `RUNNING`/`COMPLETE`. + +- [ ] **Step 2: Write failing IT — snapshot appears on success** + +Create `DeploymentSnapshotIT`: + +```java +package com.cameleer.server.app.runtime; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.util.concurrent.TimeUnit; + +@SpringBootTest +@ActiveProfiles("it") +class DeploymentSnapshotIT extends BaseIT { // reuse existing BaseIT with Postgres + Docker + + @Autowired DeploymentService deploymentService; + @Autowired DeploymentRepository deploymentRepository; + + @Test + void snapshot_isPopulated_whenDeploymentReachesRunning() throws Exception { + // given — a managed app with a JAR version and config saved + String envSlug = seedEnv("snap-it").slug(); + String appSlug = seedApp(envSlug, "snap-app").slug(); + String versionId = uploadTinyJar(envSlug, appSlug).id(); + saveConfig(envSlug, appSlug, cfg -> cfg.setSamplingRate(0.25)); + + // when — deploy + UUID deploymentId = deploymentService.create(envSlug, appSlug, versionId).id(); + + // then — wait for RUNNING, snapshot is populated + await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { + Deployment d = deploymentRepository.findById(deploymentId).orElseThrow(); + assertThat(d.status()).isEqualTo(DeploymentStatus.RUNNING); + assertThat(d.deployedConfigSnapshot()).isNotNull(); + assertThat(d.deployedConfigSnapshot().jarVersionId()).isEqualTo(versionId); + assertThat(d.deployedConfigSnapshot().agentConfig().getSamplingRate()).isEqualTo(0.25); + }); + } + + @Test + void snapshot_isNotPopulated_whenDeploymentFails() throws Exception { + // given — deployment configured to fail at PULL_IMAGE (nonexistent image tag) + String envSlug = seedEnv("snap-fail").slug(); + String appSlug = seedApp(envSlug, "fail-app").slug(); + String versionId = uploadUnresolvableJar(envSlug, appSlug).id(); // helper forces PULL_IMAGE fail + + UUID deploymentId = deploymentService.create(envSlug, appSlug, versionId).id(); + + await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { + Deployment d = deploymentRepository.findById(deploymentId).orElseThrow(); + assertThat(d.status()).isEqualTo(DeploymentStatus.FAILED); + assertThat(d.deployedConfigSnapshot()).isNull(); + }); + } +} +``` + +Run: + +```bash +mvn -pl cameleer-server-app test -Dtest=DeploymentSnapshotIT +``` + +Expected: FAIL — snapshot never set by the executor. + +- [ ] **Step 3: Write snapshot in DeploymentExecutor on successful COMPLETE** + +Find the success path (where status transitions to `RUNNING`, `deployedAt` is set) and inject: + +```java +// Before persisting the RUNNING transition +ApplicationConfig agentConfig = configRepository + .findByApplicationAndEnvironment(app.slug(), env.slug()) + .orElse(null); +Map containerConfig = app.containerConfig(); // Map already + +DeploymentConfigSnapshot snapshot = new DeploymentConfigSnapshot( + deployment.appVersionId().toString(), + agentConfig, + containerConfig +); + +Deployment updated = deployment + .withStatus(DeploymentStatus.RUNNING) + .withDeployStage("COMPLETE") + .withDeployedAt(Instant.now()) + .withDeployedConfigSnapshot(snapshot); +deploymentRepository.save(updated); +``` + +Adapt field names to whichever "with..." helpers the existing `Deployment` record provides. If the record has no wither helpers, construct a new `Deployment(...)` with all fields. + +Do **not** populate snapshot on FAIL paths — leave as null. + +- [ ] **Step 4: Re-run IT** + +Run: + +```bash +mvn -pl cameleer-server-app test -Dtest=DeploymentSnapshotIT +``` + +Expected: both tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java \ + cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java +git commit -m "runtime(deploy): capture config snapshot on RUNNING transition" +``` + +--- + +## Phase 2 — Backend: staged vs live config writes + +### Task 2.1: `?apply=staged|live` query param on `ApplicationConfigController` + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ApplicationConfigController.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ApplicationConfigControllerIT.java` (add tests, do not replace) + +- [ ] **Step 1: Write failing IT — staged write does not push** + +Add to `ApplicationConfigControllerIT`: + +```java +@Test +void putConfig_staged_savesButDoesNotPush() throws Exception { + // given — one LIVE agent for (app, env) + String envSlug = seedEnv("staged-env").slug(); + seedAgent(envSlug, "paygw", AgentState.LIVE); + + ApplicationConfig cfg = new ApplicationConfig(); + cfg.setApplication("paygw"); + cfg.setSamplingRate(0.1); + + // when — PUT with apply=staged + mockMvc.perform(put("/api/v1/environments/" + envSlug + "/apps/paygw/config") + .param("apply", "staged") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(cfg))) + .andExpect(status().isOk()); + + // then — DB has the new config + ApplicationConfig saved = configRepository.findByApplicationAndEnvironment("paygw", envSlug).orElseThrow(); + assertThat(saved.getSamplingRate()).isEqualTo(0.1); + + // and — no CONFIG_UPDATE command was pushed + List pushed = agentRegistryService.findPushedCommands(); // test helper on a spy/recorder + assertThat(pushed).noneMatch(c -> c.type() == CommandType.CONFIG_UPDATE); +} + +@Test +void putConfig_live_savesAndPushes() throws Exception { + String envSlug = seedEnv("live-env").slug(); + seedAgent(envSlug, "paygw", AgentState.LIVE); + + ApplicationConfig cfg = new ApplicationConfig(); + cfg.setApplication("paygw"); + cfg.setSamplingRate(0.2); + + mockMvc.perform(put("/api/v1/environments/" + envSlug + "/apps/paygw/config") + // no apply param → default live + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(cfg))) + .andExpect(status().isOk()); + + List pushed = agentRegistryService.findPushedCommands(); + assertThat(pushed).anyMatch(c -> c.type() == CommandType.CONFIG_UPDATE); +} +``` + +Run: + +```bash +mvn -pl cameleer-server-app test -Dtest=ApplicationConfigControllerIT#putConfig_staged_savesButDoesNotPush +``` + +Expected: FAIL — there is no `apply` param, push happens unconditionally. + +- [ ] **Step 2: Add the query param and gate the push** + +Modify `ApplicationConfigController.updateConfig`: + +```java +@PutMapping("/apps/{appSlug}/config") +@Operation(summary = "Update application config for this environment", + description = "Saves config. When apply=live (default), also pushes CONFIG_UPDATE to LIVE agents. " + + "When apply=staged, persists without a live push — the next successful deploy applies it.") +@ApiResponse(responseCode = "200", description = "Config saved (and pushed if apply=live)") +public ResponseEntity updateConfig(@EnvPath Environment env, + @PathVariable String appSlug, + @RequestParam(name = "apply", defaultValue = "live") String apply, + @RequestBody ApplicationConfig config, + Authentication auth, + HttpServletRequest httpRequest) { + String updatedBy = auth != null ? auth.getName() : "system"; + config.setApplication(appSlug); + ApplicationConfig saved = configRepository.save(appSlug, env.slug(), config, updatedBy); + + List globalKeys = sensitiveKeysRepository.find() + .map(SensitiveKeysConfig::keys) + .orElse(null); + List perAppKeys = extractSensitiveKeys(saved); + List mergedKeys = SensitiveKeysMerger.merge(globalKeys, perAppKeys); + + CommandGroupResponse pushResult; + if ("staged".equalsIgnoreCase(apply)) { + pushResult = new CommandGroupResponse(true, 0, 0, List.of(), List.of()); + log.info("Config v{} staged for '{}' (no live push)", saved.getVersion(), appSlug); + } else { + pushResult = pushConfigToAgentsWithMergedKeys(appSlug, env.slug(), saved, mergedKeys); + log.info("Config v{} saved for '{}', pushed to {} agent(s), {} responded", + saved.getVersion(), appSlug, pushResult.total(), pushResult.responded()); + } + + auditService.log( + "staged".equalsIgnoreCase(apply) ? "stage_app_config" : "update_app_config", + AuditCategory.CONFIG, appSlug, + Map.of("environment", env.slug(), "version", saved.getVersion(), + "apply", apply, + "agentsPushed", pushResult.total(), + "responded", pushResult.responded(), + "timedOut", pushResult.timedOut().size()), + AuditResult.SUCCESS, httpRequest); + + return ResponseEntity.ok(new ConfigUpdateResponse(saved, pushResult)); +} +``` + +Reject unknown values: + +```java +if (!"staged".equalsIgnoreCase(apply) && !"live".equalsIgnoreCase(apply)) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ConfigUpdateResponse(null, null)); // or throw 400 via exception handler +} +``` + +(Put the validation at the top of the method, before the save.) + +- [ ] **Step 3: Re-run both IT tests** + +Run: + +```bash +mvn -pl cameleer-server-app test -Dtest=ApplicationConfigControllerIT#putConfig_staged_savesButDoesNotPush+putConfig_live_savesAndPushes +``` + +Expected: both PASS. + +- [ ] **Step 4: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ApplicationConfigController.java \ + cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ApplicationConfigControllerIT.java +git commit -m "api(config): ?apply=staged|live gates SSE push on PUT /apps/{slug}/config" +``` + +--- + +## Phase 3 — Backend: dirty-state endpoint + +### Task 3.1: `DirtyStateCalculator` pure service + unit test + +**Files:** +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateCalculator.java` +- Create: `cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/DirtyStateCalculatorTest.java` + +- [ ] **Step 1: Write failing unit test** + +```java +package com.cameleer.server.core.runtime; + +import com.cameleer.common.model.ApplicationConfig; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class DirtyStateCalculatorTest { + + @Test + void noSnapshot_meansEverythingDirty() { + DirtyStateCalculator calc = new DirtyStateCalculator(); + + ApplicationConfig desiredAgent = new ApplicationConfig(); + desiredAgent.setSamplingRate(1.0); + Map desiredContainer = Map.of("memoryLimitMb", 512); + + DirtyStateResult result = calc.compute("v1", desiredAgent, desiredContainer, null); + + assertThat(result.dirty()).isTrue(); + assertThat(result.differences()).extracting(DirtyStateResult.Difference::field) + .contains("snapshot"); // sentinel for "no snapshot" + } + + @Test + void identicalSnapshot_isClean() { + DirtyStateCalculator calc = new DirtyStateCalculator(); + + ApplicationConfig cfg = new ApplicationConfig(); + cfg.setSamplingRate(0.5); + Map container = Map.of("memoryLimitMb", 512); + + DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot("v1", cfg, container); + DirtyStateResult result = calc.compute("v1", cfg, container, snap); + + assertThat(result.dirty()).isFalse(); + assertThat(result.differences()).isEmpty(); + } + + @Test + void differentJar_marksJarField() { + DirtyStateCalculator calc = new DirtyStateCalculator(); + ApplicationConfig cfg = new ApplicationConfig(); + Map container = Map.of(); + DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot("v1", cfg, container); + + DirtyStateResult result = calc.compute("v2", cfg, container, snap); + + assertThat(result.dirty()).isTrue(); + assertThat(result.differences()).extracting(DirtyStateResult.Difference::field) + .contains("jarVersionId"); + } + + @Test + void differentSamplingRate_marksAgentField() { + DirtyStateCalculator calc = new DirtyStateCalculator(); + + ApplicationConfig deployedCfg = new ApplicationConfig(); + deployedCfg.setSamplingRate(0.5); + ApplicationConfig desiredCfg = new ApplicationConfig(); + desiredCfg.setSamplingRate(1.0); + DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot("v1", deployedCfg, Map.of()); + + DirtyStateResult result = calc.compute("v1", desiredCfg, Map.of(), snap); + + assertThat(result.dirty()).isTrue(); + assertThat(result.differences()).extracting(DirtyStateResult.Difference::field) + .contains("agentConfig.samplingRate"); + } + + @Test + void differentContainerMemory_marksContainerField() { + DirtyStateCalculator calc = new DirtyStateCalculator(); + ApplicationConfig cfg = new ApplicationConfig(); + DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot("v1", cfg, Map.of("memoryLimitMb", 512)); + + DirtyStateResult result = calc.compute("v1", cfg, Map.of("memoryLimitMb", 1024), snap); + + assertThat(result.dirty()).isTrue(); + assertThat(result.differences()).extracting(DirtyStateResult.Difference::field) + .contains("containerConfig.memoryLimitMb"); + } +} +``` + +Run: + +```bash +mvn -pl cameleer-server-core test -Dtest=DirtyStateCalculatorTest +``` + +Expected: FAIL — class doesn't exist. + +- [ ] **Step 2: Write the implementation** + +```java +package com.cameleer.server.core.runtime; + +import com.cameleer.common.model.ApplicationConfig; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Compares the app's current desired state (JAR + agent config + container config) to the + * config snapshot from the last successful deployment, producing a structured dirty result. + * + *

Pure logic — no IO, no Spring. Safe to unit-test as a POJO.

+ */ +public class DirtyStateCalculator { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + public DirtyStateResult compute(String desiredJarVersionId, + ApplicationConfig desiredAgentConfig, + Map desiredContainerConfig, + DeploymentConfigSnapshot snapshot) { + List diffs = new ArrayList<>(); + + if (snapshot == null) { + diffs.add(new DirtyStateResult.Difference("snapshot", "(none)", "(none)")); + return new DirtyStateResult(true, diffs); + } + + if (!Objects.equals(desiredJarVersionId, snapshot.jarVersionId())) { + diffs.add(new DirtyStateResult.Difference("jarVersionId", + String.valueOf(desiredJarVersionId), String.valueOf(snapshot.jarVersionId()))); + } + + compareJson("agentConfig", MAPPER.valueToTree(desiredAgentConfig), + MAPPER.valueToTree(snapshot.agentConfig()), diffs); + compareJson("containerConfig", MAPPER.valueToTree(desiredContainerConfig), + MAPPER.valueToTree(snapshot.containerConfig()), diffs); + + return new DirtyStateResult(!diffs.isEmpty(), diffs); + } + + private void compareJson(String prefix, JsonNode desired, JsonNode deployed, + List diffs) { + if (!(desired instanceof ObjectNode desiredObj) || !(deployed instanceof ObjectNode deployedObj)) { + if (!Objects.equals(desired, deployed)) { + diffs.add(new DirtyStateResult.Difference(prefix, String.valueOf(desired), String.valueOf(deployed))); + } + return; + } + // union of keys + java.util.Set keys = new java.util.TreeSet<>(); + desiredObj.fieldNames().forEachRemaining(keys::add); + deployedObj.fieldNames().forEachRemaining(keys::add); + for (String key : keys) { + JsonNode d = desiredObj.get(key); + JsonNode p = deployedObj.get(key); + if (!Objects.equals(d, p)) { + diffs.add(new DirtyStateResult.Difference(prefix + "." + key, + String.valueOf(d), String.valueOf(p))); + } + } + } +} +``` + +Create the result record: + +```java +// cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateResult.java +package com.cameleer.server.core.runtime; + +import java.util.List; + +public record DirtyStateResult(boolean dirty, List differences) { + public record Difference(String field, String staged, String deployed) {} +} +``` + +- [ ] **Step 3: Re-run unit test** + +Run: + +```bash +mvn -pl cameleer-server-core test -Dtest=DirtyStateCalculatorTest +``` + +Expected: all PASS. + +- [ ] **Step 4: Commit** + +```bash +git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateCalculator.java \ + cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateResult.java \ + cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/DirtyStateCalculatorTest.java +git commit -m "core(deploy): add DirtyStateCalculator + DirtyStateResult" +``` + +### Task 3.2: `GET /apps/{appSlug}/dirty-state` endpoint on `AppController` + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppController.java` +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/dto/DirtyStateResponse.java` +- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AppDirtyStateIT.java` + +- [ ] **Step 1: Write DTO** + +```java +// cameleer-server-app/src/main/java/com/cameleer/server/app/dto/DirtyStateResponse.java +package com.cameleer.server.app.dto; + +import com.cameleer.server.core.runtime.DirtyStateResult; + +import java.util.List; + +public record DirtyStateResponse( + boolean dirty, + String lastSuccessfulDeploymentId, + List differences +) { +} +``` + +- [ ] **Step 2: Write failing IT** + +```java +package com.cameleer.server.app.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +// imports... + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class AppDirtyStateIT extends BaseIT { + + @Test + void dirtyState_noDeployEver_returnsDirtyTrue() throws Exception { + String envSlug = seedEnv("d1").slug(); + String appSlug = seedApp(envSlug, "paygw").slug(); + uploadTinyJar(envSlug, appSlug); + saveConfig(envSlug, appSlug, c -> c.setSamplingRate(0.5)); + + mockMvc.perform(get("/api/v1/environments/{e}/apps/{a}/dirty-state", envSlug, appSlug) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.dirty").value(true)) + .andExpect(jsonPath("$.lastSuccessfulDeploymentId").doesNotExist()); + } + + @Test + void dirtyState_afterSuccessfulDeploy_matchingDb_returnsDirtyFalse() throws Exception { + String envSlug = seedEnv("d2").slug(); + String appSlug = seedApp(envSlug, "paygw").slug(); + String versionId = uploadTinyJar(envSlug, appSlug).id(); + saveConfig(envSlug, appSlug, c -> c.setSamplingRate(0.5)); + + deployAndAwaitRunning(envSlug, appSlug, versionId); + + mockMvc.perform(get("/api/v1/environments/{e}/apps/{a}/dirty-state", envSlug, appSlug) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.dirty").value(false)) + .andExpect(jsonPath("$.differences").isEmpty()); + } + + @Test + void dirtyState_afterSuccessfulDeploy_configChanged_returnsDirtyTrue() throws Exception { + String envSlug = seedEnv("d3").slug(); + String appSlug = seedApp(envSlug, "paygw").slug(); + String versionId = uploadTinyJar(envSlug, appSlug).id(); + saveConfig(envSlug, appSlug, c -> c.setSamplingRate(0.5)); + deployAndAwaitRunning(envSlug, appSlug, versionId); + + // Change config (staged) + mockMvc.perform(put("/api/v1/environments/{e}/apps/{a}/config?apply=staged", envSlug, appSlug) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + adminToken) + .content("{\"application\":\"paygw\",\"samplingRate\":1.0}")) + .andExpect(status().isOk()); + + mockMvc.perform(get("/api/v1/environments/{e}/apps/{a}/dirty-state", envSlug, appSlug) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.dirty").value(true)) + .andExpect(jsonPath("$.differences[?(@.field=='agentConfig.samplingRate')]").exists()); + } +} +``` + +Run: + +```bash +mvn -pl cameleer-server-app test -Dtest=AppDirtyStateIT +``` + +Expected: FAIL — endpoint does not exist, 404. + +- [ ] **Step 3: Add the endpoint** + +In `AppController`: + +```java +@GetMapping("/{appSlug}/dirty-state") +@Operation(summary = "Check whether the app's current config differs from the last successful deploy", + description = "Returns dirty=true when the desired state (current JAR + agent config + container config) " + + "would produce a changed deployment. When no successful deploy exists yet, dirty=true.") +@ApiResponse(responseCode = "200", description = "Dirty-state computed") +public ResponseEntity getDirtyState(@EnvPath Environment env, + @PathVariable String appSlug) { + App app = appService.getByEnvironmentAndSlug(env.id(), appSlug) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + + List versions = appVersionRepository.findByApp(app.id()); + String latestVersionId = versions.isEmpty() ? null : versions.get(0).id().toString(); + + ApplicationConfig agentConfig = configRepository + .findByApplicationAndEnvironment(appSlug, env.slug()) + .orElse(null); + + Map containerConfig = app.containerConfig(); + + Deployment lastSuccessful = deploymentRepository + .findLatestSuccessfulByAppAndEnv(app.id(), env.id()) + .orElse(null); + + DeploymentConfigSnapshot snapshot = lastSuccessful != null ? lastSuccessful.deployedConfigSnapshot() : null; + + DirtyStateResult result = dirtyCalc.compute(latestVersionId, agentConfig, containerConfig, snapshot); + + String lastId = lastSuccessful != null ? lastSuccessful.id().toString() : null; + return ResponseEntity.ok(new DirtyStateResponse(result.dirty(), lastId, result.differences())); +} +``` + +Wire `DirtyStateCalculator` as a `@Bean` in `RuntimeBeanConfig` (or register via `@Component` annotation on the class). + +Add `findLatestSuccessfulByAppAndEnv` to `PostgresDeploymentRepository`: + +```java +public Optional findLatestSuccessfulByAppAndEnv(UUID appId, UUID envId) { + return jdbcTemplate.query( + "SELECT * FROM deployments WHERE app_id = ? AND environment_id = ? " + + "AND status = 'RUNNING' AND deployed_config_snapshot IS NOT NULL " + + "ORDER BY deployed_at DESC LIMIT 1", + rowMapper, appId, envId + ).stream().findFirst(); +} +``` + +- [ ] **Step 4: Re-run IT** + +Run: + +```bash +mvn -pl cameleer-server-app test -Dtest=AppDirtyStateIT +``` + +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "api(apps): GET /apps/{slug}/dirty-state returns desired-vs-deployed diff" +``` + +--- + +## Phase 4 — Regenerate OpenAPI + TypeScript types + +### Task 4.1: Regenerate and commit + +**Files:** +- Modify: `ui/src/api/openapi.json` +- Modify: `ui/src/api/schema.d.ts` + +- [ ] **Step 1: Start the backend locally** + +Per your local-services policy, start Postgres + ClickHouse + cameleer-server. Minimal flow (adapt to your `docker-compose` / `mvn spring-boot:run` setup): + +```bash +# Terminal 1: backend on :8081 per project convention +mvn -pl cameleer-server-app spring-boot:run +``` + +Wait until log shows `Started CameleerServerApplication`. + +- [ ] **Step 2: Regenerate types** + +```bash +cd ui && npm run generate-api:live +``` + +Expected: `ui/src/api/openapi.json` updated; `ui/src/api/schema.d.ts` updated with new `operations["getDirtyState"]`, new `apply` query param on `updateConfig`, and new `deployedConfigSnapshot` in the `Deployment` schema. + +- [ ] **Step 3: Sanity-check changes** + +```bash +git diff --stat ui/src/api/ +``` + +Expected: both files changed. + +```bash +grep -n "dirty-state\|deployedConfigSnapshot\|\"apply\"" ui/src/api/openapi.json +``` + +Expected: hits for all three. + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/api/openapi.json ui/src/api/schema.d.ts +git commit -m "api(schema): regenerate OpenAPI + schema.d.ts for deployment page" +``` + +--- + +## Phase 5 — UI: scaffolding and routing + +### Task 5.1: Create empty `AppDeploymentPage` shell + +**Files:** +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/index.tsx` +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css` + +- [ ] **Step 1: Write the shell** + +```tsx +// ui/src/pages/AppsTab/AppDeploymentPage/index.tsx +import { useParams, useLocation } from 'react-router'; +import { useEnvironmentStore } from '../../../api/environment-store'; +import { useEnvironments } from '../../../api/queries/admin/environments'; +import { useApps } from '../../../api/queries/admin/apps'; +import { PageLoader } from '../../../components/PageLoader'; +import styles from './AppDeploymentPage.module.css'; + +export default function AppDeploymentPage() { + const { appId } = useParams<{ appId?: string }>(); + const location = useLocation(); + const selectedEnv = useEnvironmentStore((s) => s.environment); + const { data: environments = [], isLoading: envLoading } = useEnvironments(); + const { data: apps = [], isLoading: appsLoading } = useApps(selectedEnv); + + const isNetNew = location.pathname.endsWith('/apps/new'); + const app = isNetNew ? null : apps.find((a) => a.slug === appId) ?? null; + + if (envLoading || appsLoading) return ; + + return ( +
+

{app ? app.displayName : 'Create Application'}

+ {/* Identity section, tabs, primary button land in subsequent tasks */} +
+ ); +} +``` + +```css +/* ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css */ +.container { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px 24px; + min-height: 100%; +} +``` + +- [ ] **Step 2: Update router** + +Modify `ui/src/router.tsx`. Replace the existing `AppsTab` route block (two routes — list + detail + new) with: + +```tsx +// before the AppsTab import: +import AppDeploymentPage from './pages/AppsTab/AppDeploymentPage'; + +// inside the routes array: +{ path: 'apps', element: }, // list stays +{ path: 'apps/new', element: }, +{ path: 'apps/:appId', element: }, +``` + +Important: `apps/new` must be declared **before** `apps/:appId` so the static path matches first. + +- [ ] **Step 3: Verify dev build** + +Run: + +```bash +cd ui && npm run build +``` + +Expected: BUILD SUCCESS, no TS errors. + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/pages/AppsTab/AppDeploymentPage/ ui/src/router.tsx +git commit -m "ui(deploy): scaffold AppDeploymentPage + route /apps/new and /apps/:slug" +``` + +### Task 5.2: Add `useDirtyState` hook + +**Files:** +- Modify: `ui/src/api/queries/admin/apps.ts` + +- [ ] **Step 1: Add types + hook** + +Append to `ui/src/api/queries/admin/apps.ts`: + +```typescript +export interface DirtyStateDifference { + field: string; + staged: string; + deployed: string; +} + +export interface DirtyState { + dirty: boolean; + lastSuccessfulDeploymentId: string | null; + differences: DirtyStateDifference[]; +} + +export function useDirtyState(envSlug: string | undefined, appSlug: string | undefined) { + return useQuery({ + queryKey: ['apps', envSlug, appSlug, 'dirty-state'], + queryFn: () => apiFetch( + `${envBase(envSlug!)}/${encodeURIComponent(appSlug!)}/dirty-state`, + ), + enabled: !!envSlug && !!appSlug, + }); +} +``` + +- [ ] **Step 2: Extend `useUpdateApplicationConfig` with `apply`** + +Find `useUpdateApplicationConfig` in `ui/src/api/queries/commands.ts` (per import in AppsTab.tsx). Read the existing signature, then add `apply?: 'staged' | 'live'` defaulting to `'live'`: + +```typescript +export function useUpdateApplicationConfig() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ config, environment, apply = 'live' }: { + config: ApplicationConfig; + environment: string; + apply?: 'staged' | 'live'; + }) => apiFetch( + `/environments/${encodeURIComponent(environment)}/apps/${encodeURIComponent(config.application)}/config?apply=${apply}`, + { method: 'PUT', body: JSON.stringify(config) }, + ), + // ... existing onSuccess + }); +} +``` + +Preserve existing invalidation logic. + +- [ ] **Step 3: TS build** + +```bash +cd ui && npm run build +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/api/queries/ +git commit -m "ui(api): add useDirtyState + apply=staged|live on useUpdateApplicationConfig" +``` + +--- + +## Phase 6 — UI: Identity & Artifact section + +### Task 6.1: `deriveAppName` pure function + test + +**Files:** +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/utils/deriveAppName.ts` +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/utils/deriveAppName.test.ts` + +- [ ] **Step 1: Write failing test** + +```typescript +// ui/src/pages/AppsTab/AppDeploymentPage/utils/deriveAppName.test.ts +import { describe, it, expect } from 'vitest'; +import { deriveAppName } from './deriveAppName'; + +describe('deriveAppName', () => { + it('truncates at first digit', () => { + expect(deriveAppName('payment-gateway-1.2.0.jar')).toBe('Payment Gateway'); + }); + + it('returns clean title-cased name without digits', () => { + expect(deriveAppName('order-service.jar')).toBe('Order Service'); + }); + + it('strips orphan 1-char token after truncation (v from my-app-v2)', () => { + expect(deriveAppName('my-app-v2.jar')).toBe('My App'); + }); + + it('treats underscore like dash', () => { + expect(deriveAppName('acme_billing-3.jar')).toBe('Acme Billing'); + }); + + it('strips the .jar extension when no digits present', () => { + expect(deriveAppName('acme-billing.jar')).toBe('Acme Billing'); + }); + + it('returns empty string for empty input', () => { + expect(deriveAppName('')).toBe(''); + }); + + it('returns empty string when filename starts with a digit', () => { + expect(deriveAppName('1-my-thing.jar')).toBe(''); + }); + + it('mixed separators are both collapsed to spaces', () => { + expect(deriveAppName('foo_bar-baz.jar')).toBe('Foo Bar Baz'); + }); + + it('strips trailing orphan regardless of letter identity', () => { + expect(deriveAppName('release-x9.jar')).toBe('Release'); + }); +}); +``` + +Run: + +```bash +cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/utils/deriveAppName.test.ts +``` + +Expected: FAIL (module doesn't exist). + +- [ ] **Step 2: Write the implementation** + +```typescript +// ui/src/pages/AppsTab/AppDeploymentPage/utils/deriveAppName.ts +/** + * Derive a human-readable app name from a JAR filename. + * + * Rule: + * 1. Strip the `.jar` extension. + * 2. Truncate at the first digit (0-9) or `.`. + * 3. Replace `-` and `_` with spaces. + * 4. Collapse multiple spaces and trim. + * 5. Drop 1-char orphan tokens (e.g. the trailing `v` in `my-app-v2`). + * 6. Title-case each remaining word. + * + * The result is a *suggestion* — the caller is expected to let the user override. + */ +export function deriveAppName(filename: string): string { + if (!filename) return ''; + + let stem = filename.replace(/\.jar$/i, ''); + + // Truncate at first digit or dot + const match = stem.match(/[0-9.]/); + if (match && match.index !== undefined) { + stem = stem.slice(0, match.index); + } + + // Separators → space + stem = stem.replace(/[-_]+/g, ' '); + + // Collapse whitespace + trim + stem = stem.replace(/\s+/g, ' ').trim(); + if (!stem) return ''; + + // Drop 1-char orphan tokens + const tokens = stem.split(' ').filter((t) => t.length > 1); + if (tokens.length === 0) return ''; + + // Title-case + return tokens.map((t) => t.charAt(0).toUpperCase() + t.slice(1).toLowerCase()).join(' '); +} +``` + +- [ ] **Step 3: Re-run test** + +```bash +cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/utils/deriveAppName.test.ts +``` + +Expected: all PASS. + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/pages/AppsTab/AppDeploymentPage/utils/ +git commit -m "ui(deploy): add deriveAppName pure function + tests" +``` + +### Task 6.2: Identity & Artifact component + +**Files:** +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx` + +- [ ] **Step 1: Write the component** + +```tsx +// ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx +import { useRef } from 'react'; +import { SectionHeader, Input, MonoText, Button, Badge } from '@cameleer/design-system'; +import type { App, AppVersion } from '../../../api/queries/admin/apps'; +import type { Environment } from '../../../api/queries/admin/environments'; +import styles from './AppDeploymentPage.module.css'; + +function slugify(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 100); +} + +function formatBytes(bytes: number): string { + if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${bytes} B`; +} + +interface IdentitySectionProps { + mode: 'net-new' | 'deployed'; + environment: Environment; + app: App | null; + currentVersion: AppVersion | null; + name: string; + onNameChange: (next: string) => void; + stagedJar: File | null; + onStagedJarChange: (file: File | null) => void; + deploying: boolean; +} + +export function IdentitySection({ + mode, environment, app, currentVersion, + name, onNameChange, stagedJar, onStagedJarChange, deploying, +}: IdentitySectionProps) { + const fileInputRef = useRef(null); + const slug = app?.slug ?? slugify(name); + const externalUrl = (() => { + const defaults = environment.defaultContainerConfig ?? {}; + const domain = String(defaults.routingDomain ?? ''); + if (defaults.routingMode === 'subdomain' && domain) { + return `https://${slug || '...'}-${environment.slug}.${domain}/`; + } + const base = domain ? `https://${domain}` : window.location.origin; + return `${base}/${environment.slug}/${slug || '...'}/`; + })(); + + return ( +
+ Identity & Artifact +
+ Application Name + {mode === 'deployed' ? ( + {name} + ) : ( + onNameChange(e.target.value)} + placeholder="e.g. Payment Gateway" + disabled={deploying} + /> + )} + + Slug + {slug || '...'} + + Environment + + + External URL + {externalUrl} + + {currentVersion && ( + <> + Current Version + + v{currentVersion.version} · {currentVersion.jarFilename} · {formatBytes(currentVersion.jarSizeBytes)} + + + )} + + Application JAR +
+ onStagedJarChange(e.target.files?.[0] ?? null)} + /> + + {stagedJar && ( + + staged: {stagedJar.name} ({formatBytes(stagedJar.size)}) + + )} +
+
+
+ ); +} +``` + +Add the new CSS classes to `AppDeploymentPage.module.css`: + +```css +.section { + border: 1px solid var(--border); + border-radius: 6px; + padding: 16px; + background: var(--surface); +} + +.configGrid { + display: grid; + grid-template-columns: 180px 1fr; + gap: 10px 16px; + align-items: center; + margin-top: 8px; +} + +.configLabel { + color: var(--text-muted); + font-size: 13px; +} + +.readOnlyValue { + color: var(--text); + font-size: 14px; +} + +.fileRow { + display: flex; + align-items: center; + gap: 10px; +} + +.stagedJar { + color: var(--amber); + font-size: 13px; +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +``` + +- [ ] **Step 2: Wire auto-derive into `AppDeploymentPage`** + +Edit `ui/src/pages/AppsTab/AppDeploymentPage/index.tsx`: + +```tsx +import { useState, useEffect, useRef } from 'react'; +import { IdentitySection } from './IdentitySection'; +import { deriveAppName } from './utils/deriveAppName'; +// ... existing imports + +export default function AppDeploymentPage() { + // ... existing (appId, selectedEnv, etc.) + + const env = environments.find((e) => e.slug === selectedEnv); + + // Form state + const [name, setName] = useState(app?.displayName ?? ''); + const [stagedJar, setStagedJar] = useState(null); + + // Auto-derive: update name from staged JAR if user hasn't typed anything custom + const lastDerivedRef = useRef(''); + useEffect(() => { + if (!stagedJar) return; + if (app) return; // only in net-new mode + const derived = deriveAppName(stagedJar.name); + if (!name || name === lastDerivedRef.current) { + setName(derived); + lastDerivedRef.current = derived; + } + }, [stagedJar, app, name]); + + if (envLoading || appsLoading) return ; + if (!env) return
Select an environment first.
; + + const mode: 'net-new' | 'deployed' = app ? 'deployed' : 'net-new'; + + return ( +
+

{app ? app.displayName : 'Create Application'}

+ +
+ ); +} +``` + +- [ ] **Step 3: Build** + +```bash +cd ui && npm run build +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/pages/AppsTab/AppDeploymentPage/ +git commit -m "ui(deploy): Identity & Artifact section with filename auto-derive" +``` + +### Task 6.3: Checkpoints disclosure + +**Files:** +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx` + +- [ ] **Step 1: Write the component** + +```tsx +// ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx +import { useState } from 'react'; +import { Button, 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 CheckpointsProps { + deployments: Deployment[]; + versions: AppVersion[]; + currentDeploymentId: string | null; + onRestore: (deploymentId: string) => void; +} + +export function Checkpoints({ deployments, versions, currentDeploymentId, onRestore }: CheckpointsProps) { + const [open, setOpen] = useState(false); + const versionMap = new Map(versions.map((v) => [v.id, v])); + + // Only successful deployments with a snapshot. Exclude the currently-running one. + const checkpoints = deployments + .filter((d) => d.deployedAt && d.status === 'RUNNING' && d.id !== currentDeploymentId) + .sort((a, b) => (b.deployedAt ?? '').localeCompare(a.deployedAt ?? '')); + + return ( +
+ + {open && ( +
+ {checkpoints.length === 0 && ( +
No past deployments yet.
+ )} + {checkpoints.map((d) => { + const v = versionMap.get(d.appVersionId); + const jarAvailable = !!v; + return ( +
+ + + {d.deployedAt ? timeAgo(d.deployedAt) : '—'} + + {!jarAvailable && ( + archived, JAR unavailable + )} + +
+ ); + })} +
+ )} +
+ ); +} +``` + +Add CSS: + +```css +.checkpointsRow { + grid-column: 2 / 3; +} +.disclosureToggle { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 13px; + padding: 4px 0; +} +.checkpointList { + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px 0 0 12px; +} +.checkpointRow { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; +} +.checkpointMeta { color: var(--text-muted); } +.checkpointArchived { color: var(--warning); font-size: 12px; } +.checkpointEmpty { color: var(--text-muted); font-size: 13px; } +``` + +- [ ] **Step 2: Render it under Identity section in net-new and deployed modes** + +Inside `IdentitySection.tsx`, accept a `children` prop or a `checkpointsSlot` prop and render it in the last grid cell. Simpler: render Checkpoints as a sibling of IdentitySection inside `AppDeploymentPage/index.tsx`, positioned within the same `.section` block via a wrapper. + +Update `index.tsx`: + +```tsx +const { data: versions = [] } = useAppVersions(selectedEnv, app?.slug); +const { data: deployments = [] } = useDeployments(selectedEnv, app?.slug); +const currentVersion = versions.sort((a, b) => b.version - a.version)[0] ?? null; +const currentDeployment = deployments.find((d) => d.status === 'RUNNING') ?? null; + +// ... inside the return, below IdentitySection: +{mode === 'deployed' && ( + { + // wired in Task 10.3 + console.info('restore', id); + }} + /> +)} +``` + +- [ ] **Step 3: Build** + +```bash +cd ui && npm run build +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/pages/AppsTab/AppDeploymentPage/ +git commit -m "ui(deploy): Checkpoints disclosure (hides current deployment, flags pruned JARs)" +``` + +--- + +## Phase 7 — UI: staged config tabs + +### Task 7.1: `useDeploymentPageState` orchestrator hook + +**Files:** +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/hooks/useDeploymentPageState.ts` + +- [ ] **Step 1: Write the hook** + +```typescript +// ui/src/pages/AppsTab/AppDeploymentPage/hooks/useDeploymentPageState.ts +import { useState, useEffect, useMemo } from 'react'; +import type { ApplicationConfig } from '../../../../api/queries/commands'; +import type { App } from '../../../../api/queries/admin/apps'; + +export interface MonitoringFormState { + engineLevel: string; + payloadCaptureMode: string; + applicationLogLevel: string; + agentLogLevel: string; + metricsEnabled: boolean; + samplingRate: string; + compressSuccess: boolean; +} + +export interface ResourcesFormState { + memoryLimit: string; + memoryReserve: string; + cpuRequest: string; + cpuLimit: string; + ports: number[]; + appPort: string; + replicas: string; + deployStrategy: string; + stripPrefix: boolean; + sslOffloading: boolean; + runtimeType: string; + customArgs: string; + extraNetworks: string[]; +} + +export interface VariablesFormState { + envVars: { key: string; value: string }[]; +} + +export interface SensitiveKeysFormState { + sensitiveKeys: string[]; +} + +export interface DeploymentPageFormState { + monitoring: MonitoringFormState; + resources: ResourcesFormState; + variables: VariablesFormState; + sensitiveKeys: SensitiveKeysFormState; +} + +const defaultForm: DeploymentPageFormState = { + monitoring: { + engineLevel: 'REGULAR', + payloadCaptureMode: 'BOTH', + applicationLogLevel: 'INFO', + agentLogLevel: 'INFO', + metricsEnabled: true, + samplingRate: '1.0', + compressSuccess: false, + }, + resources: { + memoryLimit: '512', memoryReserve: '', cpuRequest: '500', cpuLimit: '', + ports: [], appPort: '8080', replicas: '1', deployStrategy: 'blue-green', + stripPrefix: true, sslOffloading: true, runtimeType: 'auto', customArgs: '', + extraNetworks: [], + }, + variables: { envVars: [] }, + sensitiveKeys: { sensitiveKeys: [] }, +}; + +export function useDeploymentPageState( + app: App | null, + agentConfig: ApplicationConfig | null, + envDefaults: Record, +): { + form: DeploymentPageFormState; + setForm: React.Dispatch>; + reset: () => void; +} { + const [form, setForm] = useState(defaultForm); + + const serverState = useMemo(() => { + const merged = { ...envDefaults, ...(app?.containerConfig ?? {}) } as Record; + return { + monitoring: { + engineLevel: (agentConfig?.engineLevel as string) ?? 'REGULAR', + payloadCaptureMode: (agentConfig?.payloadCaptureMode as string) ?? 'BOTH', + applicationLogLevel: (agentConfig?.applicationLogLevel as string) ?? 'INFO', + agentLogLevel: (agentConfig?.agentLogLevel as string) ?? 'INFO', + metricsEnabled: agentConfig?.metricsEnabled ?? true, + samplingRate: String(agentConfig?.samplingRate ?? 1.0), + compressSuccess: agentConfig?.compressSuccess ?? false, + }, + resources: { + memoryLimit: String(merged.memoryLimitMb ?? 512), + memoryReserve: String(merged.memoryReserveMb ?? ''), + cpuRequest: String(merged.cpuRequest ?? 500), + cpuLimit: String(merged.cpuLimit ?? ''), + ports: Array.isArray(merged.exposedPorts) ? (merged.exposedPorts as number[]) : [], + appPort: String(merged.appPort ?? 8080), + replicas: String(merged.replicas ?? 1), + deployStrategy: String(merged.deploymentStrategy ?? 'blue-green'), + stripPrefix: merged.stripPathPrefix !== false, + sslOffloading: merged.sslOffloading !== false, + runtimeType: String(merged.runtimeType ?? 'auto'), + customArgs: String(merged.customArgs ?? ''), + extraNetworks: Array.isArray(merged.extraNetworks) ? (merged.extraNetworks as string[]) : [], + }, + variables: { + envVars: merged.customEnvVars + ? Object.entries(merged.customEnvVars as Record).map(([key, value]) => ({ key, value })) + : [], + }, + sensitiveKeys: { + sensitiveKeys: Array.isArray(agentConfig?.sensitiveKeys) + ? (agentConfig!.sensitiveKeys as string[]) + : [], + }, + }; + }, [app, agentConfig, envDefaults]); + + useEffect(() => { + setForm(serverState); + }, [serverState]); + + return { form, setForm, reset: () => setForm(serverState) }; +} +``` + +- [ ] **Step 2: Build** + +```bash +cd ui && npm run build +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/pages/AppsTab/AppDeploymentPage/hooks/ +git commit -m "ui(deploy): add useDeploymentPageState orchestrator hook" +``` + +### Task 7.2: Extract MonitoringTab + +**Files:** +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/MonitoringTab.tsx` + +- [ ] **Step 1: Copy monitoring form from existing AppsTab.tsx** + +Read lines in `ui/src/pages/AppsTab/AppsTab.tsx` that render the Monitoring form inside `CreateAppView` (search `engineLevel` between ~400–600). The fields are: engineLevel, payloadCapture + size/unit, appLogLevel, agentLogLevel, metricsEnabled, metricsInterval, samplingRate, compressSuccess, replayEnabled, routeControlEnabled. + +Port this JSX into a new component that takes the `MonitoringFormState` plus an `onChange` callback: + +```tsx +// ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/MonitoringTab.tsx +import { Select, Toggle, Input } from '@cameleer/design-system'; +import type { MonitoringFormState } from '../hooks/useDeploymentPageState'; +import styles from '../AppDeploymentPage.module.css'; + +interface Props { + value: MonitoringFormState; + onChange: (next: MonitoringFormState) => void; + disabled?: boolean; +} + +export function MonitoringTab({ value, onChange, disabled }: Props) { + const update = (key: K, v: MonitoringFormState[K]) => + onChange({ ...value, [key]: v }); + + return ( +
+ Engine Level + update('payloadCaptureMode', e.target.value)} + options={[ + { value: 'NONE', label: 'NONE' }, + { value: 'BODY_ONLY', label: 'BODY_ONLY' }, + { value: 'HEADERS_ONLY', label: 'HEADERS_ONLY' }, + { value: 'BOTH', label: 'BOTH' }, + ]} /> + + Application Log Level + update('agentLogLevel', e.target.value)} + options={['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].map((v) => ({ value: v, label: v }))} /> + + Metrics Enabled + update('metricsEnabled', v)} /> + + Sampling Rate + update('samplingRate', e.target.value)} /> + + Compress Success Payloads + update('compressSuccess', v)} /> +
+ ); +} +``` + +- [ ] **Step 2: Build** + +```bash +cd ui && npm run build +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/MonitoringTab.tsx +git commit -m "ui(deploy): extract MonitoringTab component" +``` + +### Task 7.3: Extract ResourcesTab + +**Files:** +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/ResourcesTab.tsx` + +- [ ] **Step 1: Write component** + +Use the same lift-and-shift pattern as Task 7.2. Port the "Resources" JSX block from existing `AppsTab.tsx` (search for `memoryLimit`, `cpuRequest`, `deployStrategy`). Bind to `ResourcesFormState`. + +```tsx +// Skeleton — fields follow the existing CreateAppView markup; replicate all of them. +import { Input, Select, Toggle, Button } from '@cameleer/design-system'; +import type { ResourcesFormState } from '../hooks/useDeploymentPageState'; +import styles from '../AppDeploymentPage.module.css'; + +interface Props { + value: ResourcesFormState; + onChange: (next: ResourcesFormState) => void; + disabled?: boolean; +} + +export function ResourcesTab({ value, onChange, disabled }: Props) { + const update = (key: K, v: ResourcesFormState[K]) => + onChange({ ...value, [key]: v }); + + return ( +
+ Memory Limit (MB) + update('memoryLimit', e.target.value)} /> + + Memory Reserve (MB) + update('memoryReserve', e.target.value)} /> + + CPU Request (millicores) + update('cpuRequest', e.target.value)} /> + + CPU Limit (millicores) + update('cpuLimit', e.target.value)} /> + + App Port + update('appPort', e.target.value)} /> + + Replicas + update('replicas', e.target.value)} /> + + Deployment Strategy + update('runtimeType', e.target.value)} + options={[ + { value: 'auto', label: 'auto' }, + { value: 'spring-boot', label: 'Spring Boot' }, + { value: 'quarkus', label: 'Quarkus' }, + { value: 'plain-jar', label: 'Plain JAR' }, + ]} /> + + Custom Args + update('customArgs', e.target.value)} /> + + {/* Ports + extraNetworks — preserve the add-list-UI from existing CreateAppView; + skipping full reproduction here for brevity but required in implementation. */} +
+ ); +} +``` + +Copy the ports list + extraNetworks add/remove UI from the existing CreateAppView sections verbatim, adapting the setter to call `update('ports', [...])`. + +- [ ] **Step 2: Build + commit** + +```bash +cd ui && npm run build +git add ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/ResourcesTab.tsx +git commit -m "ui(deploy): extract ResourcesTab component" +``` + +### Task 7.4: Extract VariablesTab + +**Files:** +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/VariablesTab.tsx` + +- [ ] **Step 1: Write component** + +```tsx +import { Input, Button } from '@cameleer/design-system'; +import type { VariablesFormState } from '../hooks/useDeploymentPageState'; +import styles from '../AppDeploymentPage.module.css'; + +interface Props { + value: VariablesFormState; + onChange: (next: VariablesFormState) => void; + disabled?: boolean; +} + +export function VariablesTab({ value, onChange, disabled }: Props) { + const update = (envVars: VariablesFormState['envVars']) => + onChange({ envVars }); + + return ( +
+ {value.envVars.map((v, i) => ( +
+ update(value.envVars.map((x, idx) => idx === i ? { ...x, key: e.target.value } : x))} /> + update(value.envVars.map((x, idx) => idx === i ? { ...x, value: e.target.value } : x))} /> + +
+ ))} + +
+ ); +} +``` + +Add CSS: + +```css +.envVarsList { display: flex; flex-direction: column; gap: 8px; } +.envVarRow { display: grid; grid-template-columns: 1fr 2fr auto; gap: 8px; align-items: center; } +``` + +- [ ] **Step 2: Build + commit** + +```bash +cd ui && npm run build +git add ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/VariablesTab.tsx ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css +git commit -m "ui(deploy): extract VariablesTab component" +``` + +### Task 7.5: Extract SensitiveKeysTab + +**Files:** +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/SensitiveKeysTab.tsx` + +- [ ] **Step 1: Write component** + +Port the `sensitive-keys` Tab block from the existing CreateAppView (it uses `skStyles` from `Admin/SensitiveKeysPage.module.css` + displays global keys). Convert to accept `SensitiveKeysFormState` + `onChange`, plus read global keys via `useSensitiveKeys` directly. + +```tsx +import { useState } from 'react'; +import { Tag, Input, Button, Badge } from '@cameleer/design-system'; +import { Info } from 'lucide-react'; +import { useSensitiveKeys } from '../../../../api/queries/admin/sensitive-keys'; +import type { SensitiveKeysFormState } from '../hooks/useDeploymentPageState'; +import skStyles from '../../../Admin/SensitiveKeysPage.module.css'; + +interface Props { + value: SensitiveKeysFormState; + onChange: (next: SensitiveKeysFormState) => void; + disabled?: boolean; +} + +export function SensitiveKeysTab({ value, onChange, disabled }: Props) { + const { data: globalKeysConfig } = useSensitiveKeys(); + const globalKeys = globalKeysConfig?.keys ?? []; + const [newKey, setNewKey] = useState(''); + + const add = () => { + const v = newKey.trim(); + if (!v) return; + if (value.sensitiveKeys.some((k) => k.toLowerCase() === v.toLowerCase())) return; + onChange({ sensitiveKeys: [...value.sensitiveKeys, v] }); + setNewKey(''); + }; + + return ( +
+ {globalKeys.length > 0 && ( +
+
Global baseline
+
+ {globalKeys.map((k) => )} +
+
+ )} + +
App-specific keys
+
+ {value.sensitiveKeys.map((k, i) => ( + !disabled && onChange({ sensitiveKeys: value.sensitiveKeys.filter((_, idx) => idx !== i) })} /> + ))} + {value.sensitiveKeys.length === 0 && ( + + No app-specific keys — agents use built-in defaults{globalKeys.length > 0 ? ' + global keys' : ''}. + + )} +
+ +
+ setNewKey(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); add(); } }} + placeholder="Add key or glob pattern (e.g. *password*)" /> + +
+ +
+ + Final masking configuration = agent defaults + global keys + app-specific keys. +
+
+ ); +} +``` + +- [ ] **Step 2: Build + commit** + +```bash +cd ui && npm run build +git add ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/SensitiveKeysTab.tsx +git commit -m "ui(deploy): extract SensitiveKeysTab component" +``` + +--- + +## Phase 8 — UI: live-apply tabs with banner + +### Task 8.1: `LiveBanner` component + +**Files:** +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/LiveBanner.tsx` + +- [ ] **Step 1: Write component** + +```tsx +// ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/LiveBanner.tsx +import { Info } from 'lucide-react'; +import styles from '../AppDeploymentPage.module.css'; + +export function LiveBanner() { + return ( +
+ + + Live controls. Changes apply immediately to running agents and do + not participate in the Save/Redeploy cycle. + +
+ ); +} +``` + +CSS: + +```css +.liveBanner { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + margin-bottom: 12px; + background: var(--amber-surface, rgba(245, 158, 11, 0.12)); + border: 1px solid var(--amber); + border-radius: 6px; + color: var(--text); + font-size: 13px; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/LiveBanner.tsx ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css +git commit -m "ui(deploy): LiveBanner component for live-apply tabs" +``` + +### Task 8.2: TracesTapsTab + RouteRecordingTab (port + wrap in banner) + +**Files:** +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/TracesTapsTab.tsx` +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/RouteRecordingTab.tsx` + +- [ ] **Step 1: Lift Traces & Taps content from existing AppsTab.tsx** + +Find the "traces" case inside `ConfigSubTab` in `AppsTab.tsx` (search `tracedTapRows`, `TracedTapRow`). Copy the rendering logic into a new component. Add `` at the top. + +These tabs continue to use `useUpdateApplicationConfig` with **default** `apply=live` (no `?apply=staged`). The key: the banner + the fact that these writes invoke the live-push variant means they apply immediately and do not feed the deployment page's form state. + +```tsx +// ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/TracesTapsTab.tsx +import { LiveBanner } from './LiveBanner'; +import type { App } from '../../../../api/queries/admin/apps'; +import type { Environment } from '../../../../api/queries/admin/environments'; + +interface Props { app: App; environment: Environment; } + +export function TracesTapsTab({ app, environment }: Props) { + return ( +
+ + {/* Paste the existing tracedTapRows rendering here verbatim — it queries + useApplicationConfig + calls useUpdateApplicationConfig (apply=live default). */} +
+ ); +} +``` + +Do the same for `RouteRecordingTab.tsx` — lift the "recording" case contents, prepend ``. + +- [ ] **Step 2: Build + commit** + +```bash +cd ui && npm run build +git add ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/ +git commit -m "ui(deploy): Traces & Taps + Route Recording tabs with live banner" +``` + +--- + +## Phase 9 — UI: Deployment tab + +### Task 9.1: `StatusCard` + +**Files:** +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/StatusCard.tsx` + +- [ ] **Step 1: Write component** + +```tsx +import { Badge, StatusDot, MonoText, Button } 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'; + +const STATUS_COLORS = { + RUNNING: 'success', STARTING: 'warning', FAILED: 'error', + STOPPED: 'auto', DEGRADED: 'warning', STOPPING: 'auto', +} as const; + +const DEPLOY_STATUS_DOT = { + RUNNING: 'live', STARTING: 'running', DEGRADED: 'stale', + STOPPING: 'stale', STOPPED: 'dead', FAILED: 'error', +} as const; + +interface Props { + deployment: Deployment; + version: AppVersion | null; + externalUrl: string; + onStop: () => void; + onStart: () => void; +} + +export function StatusCard({ deployment, version, externalUrl, onStop, onStart }: Props) { + const running = deployment.replicaStates?.filter((r) => r.status === 'RUNNING').length ?? 0; + const total = deployment.replicaStates?.length ?? 0; + + return ( +
+
+ + + {version && } +
+ +
+ {version && <>JAR{version.jarFilename}} + {version && <>Checksum{version.jarChecksum.substring(0, 12)}} + Replicas{running}/{total} + URL + {deployment.status === 'RUNNING' + ? {externalUrl} + : {externalUrl}} + Deployed{deployment.deployedAt ? timeAgo(deployment.deployedAt) : '—'} +
+ +
+ {(deployment.status === 'RUNNING' || deployment.status === 'STARTING' || deployment.status === 'DEGRADED') + && } + {deployment.status === 'STOPPED' && } +
+
+ ); +} +``` + +CSS: + +```css +.statusCard { + border: 1px solid var(--border); + border-radius: 6px; + padding: 14px; + background: var(--surface); + display: flex; + flex-direction: column; + gap: 12px; +} +.statusCardHeader { display: flex; align-items: center; gap: 8px; } +.statusCardGrid { display: grid; grid-template-columns: 100px 1fr; gap: 6px 12px; font-size: 13px; } +.statusCardActions { display: flex; gap: 8px; } +``` + +- [ ] **Step 2: Build + commit** + +```bash +cd ui && npm run build +git add ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/StatusCard.tsx ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css +git commit -m "ui(deploy): StatusCard for Deployment tab" +``` + +### Task 9.2: `HistoryDisclosure` + +**Files:** +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/HistoryDisclosure.tsx` + +- [ ] **Step 1: Write component** + +```tsx +import { useState } from 'react'; +import { DataTable } from '@cameleer/design-system'; +import type { Column } from '@cameleer/design-system'; +import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps'; +import { timeAgo } from '../../../../utils/format-utils'; +import { StartupLogPanel } from '../../../../components/StartupLogPanel'; +import styles from '../AppDeploymentPage.module.css'; + +interface Props { + deployments: Deployment[]; + versions: AppVersion[]; + appSlug: string; + envSlug: string; +} + +export function HistoryDisclosure({ deployments, versions, appSlug, envSlug }: Props) { + const [open, setOpen] = useState(false); + const [expanded, setExpanded] = useState(null); + const versionMap = new Map(versions.map((v) => [v.id, v])); + + const rows = deployments + .slice() + .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); + + const columns: Column[] = [ + { key: 'createdAt', header: 'Started', render: (_, d) => timeAgo(d.createdAt) }, + { key: 'appVersionId', header: 'Version', render: (_, d) => versionMap.get(d.appVersionId)?.version ? `v${versionMap.get(d.appVersionId)!.version}` : '?' }, + { key: 'status', header: 'Status' }, + { key: 'deployedAt', header: 'Duration', + render: (_, d) => d.deployedAt && d.createdAt + ? `${Math.round((Date.parse(d.deployedAt) - Date.parse(d.createdAt)) / 1000)}s` + : '—' }, + ]; + + return ( +
+ + {open && ( + <> + setExpanded(expanded === row.id ? null : row.id)} /> + {expanded && (() => { + const d = rows.find((r) => r.id === expanded); + if (!d) return null; + return ; + })()} + + )} +
+ ); +} +``` + +CSS: + +```css +.historyRow { margin-top: 16px; } +``` + +- [ ] **Step 2: Build + commit** + +```bash +cd ui && npm run build +git add ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/HistoryDisclosure.tsx ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css +git commit -m "ui(deploy): HistoryDisclosure with inline log expansion" +``` + +### Task 9.3: `DeploymentTab` composition + StartupLogPanel flex-grow + +**Files:** +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/DeploymentTab.tsx` +- Modify: `ui/src/components/StartupLogPanel.tsx` + +- [ ] **Step 1: Adjust StartupLogPanel to accept `className` + drop fixed maxHeight** + +Read `ui/src/components/StartupLogPanel.tsx`, then modify: + +```tsx +// StartupLogPanel.tsx signature + body: +interface StartupLogPanelProps { + deployment: Deployment; + appSlug: string; + envSlug: string; + className?: string; +} + +export function StartupLogPanel({ deployment, appSlug, envSlug, className }: StartupLogPanelProps) { + // ... existing logic ... + return ( +
+ {/* existing header */} + {entries.length > 0 ? ( + + ) : ( +
Waiting for container output...
+ )} +
+ ); +} +``` + +In `StartupLogPanel.module.css`, change `.panel` to `display: flex; flex-direction: column; flex: 1 1 auto; min-height: 200px;`. Ensure the `.logViewer` (if wrapped) has `flex: 1 1 auto; min-height: 0; overflow: auto`. + +- [ ] **Step 2: Write DeploymentTab** + +```tsx +// ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/DeploymentTab.tsx +import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps'; +import { DeploymentProgress } from '../../../../components/DeploymentProgress'; +import { StartupLogPanel } from '../../../../components/StartupLogPanel'; +import { EmptyState } from '@cameleer/design-system'; +import { StatusCard } from './StatusCard'; +import { HistoryDisclosure } from './HistoryDisclosure'; +import styles from '../AppDeploymentPage.module.css'; + +interface Props { + deployments: Deployment[]; + versions: AppVersion[]; + appSlug: string; + envSlug: string; + externalUrl: string; + onStop: (deploymentId: string) => void; + onStart: (deploymentId: string) => void; +} + +export function DeploymentTab({ deployments, versions, appSlug, envSlug, externalUrl, onStop, onStart }: Props) { + const latest = deployments + .slice() + .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))[0] ?? null; + + if (!latest) { + return ; + } + + const version = versions.find((v) => v.id === latest.appVersionId) ?? null; + + return ( +
+ onStop(latest.id)} + onStart={() => onStart(latest.id)} + /> + {latest.status === 'STARTING' && ( + + )} + {latest.status === 'FAILED' && ( + + )} + + +
+ ); +} +``` + +CSS: + +```css +.deploymentTab { + display: flex; + flex-direction: column; + gap: 12px; + flex: 1 1 auto; + min-height: 0; +} +.logFill { flex: 1 1 auto; min-height: 200px; } +``` + +- [ ] **Step 3: Build + commit** + +```bash +cd ui && npm run build +git add -A +git commit -m "ui(deploy): DeploymentTab + flex-grow StartupLogPanel" +``` + +--- + +## Phase 10 — UI: tabs, primary button, save/deploy wiring + +### Task 10.1: Dirty-state hook composition + tab-level dirty indicators + +**Files:** +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/hooks/useFormDirty.ts` + +- [ ] **Step 1: Write hook** + +```typescript +// ui/src/pages/AppsTab/AppDeploymentPage/hooks/useFormDirty.ts +import { useMemo } from 'react'; +import type { DeploymentPageFormState } from './useDeploymentPageState'; + +export interface PerTabDirty { + monitoring: boolean; + resources: boolean; + variables: boolean; + sensitiveKeys: boolean; + anyLocalEdit: boolean; +} + +export function useFormDirty( + form: DeploymentPageFormState, + serverState: DeploymentPageFormState, + stagedJar: File | null, +): PerTabDirty { + return useMemo(() => { + const monitoring = JSON.stringify(form.monitoring) !== JSON.stringify(serverState.monitoring); + const resources = JSON.stringify(form.resources) !== JSON.stringify(serverState.resources); + const variables = JSON.stringify(form.variables) !== JSON.stringify(serverState.variables); + const sensitiveKeys = JSON.stringify(form.sensitiveKeys) !== JSON.stringify(serverState.sensitiveKeys); + const anyLocalEdit = monitoring || resources || variables || sensitiveKeys || !!stagedJar; + return { monitoring, resources, variables, sensitiveKeys, anyLocalEdit }; + }, [form, serverState, stagedJar]); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/pages/AppsTab/AppDeploymentPage/hooks/useFormDirty.ts +git commit -m "ui(deploy): useFormDirty hook for per-tab dirty markers" +``` + +### Task 10.2: `PrimaryActionButton` component + +**Files:** +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx` + +- [ ] **Step 1: Write component** + +```tsx +// ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx +import { Button } from '@cameleer/design-system'; + +export type PrimaryActionMode = 'save' | 'redeploy' | 'deploying'; + +interface Props { + mode: PrimaryActionMode; + enabled: boolean; + onClick: () => void; +} + +export function PrimaryActionButton({ mode, enabled, onClick }: Props) { + if (mode === 'deploying') { + return ; + } + if (mode === 'redeploy') { + return ; + } + return ; +} + +export function computeMode({ + deploymentInProgress, + hasLocalEdits, + serverDirtyAgainstDeploy, +}: { + deploymentInProgress: boolean; + hasLocalEdits: boolean; + serverDirtyAgainstDeploy: boolean; +}): PrimaryActionMode { + if (deploymentInProgress) return 'deploying'; + if (hasLocalEdits) return 'save'; + if (serverDirtyAgainstDeploy) return 'redeploy'; + return 'save'; // idle, disabled +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx +git commit -m "ui(deploy): PrimaryActionButton + computeMode state-machine helper" +``` + +### Task 10.3: Wire everything into `AppDeploymentPage/index.tsx` + +**Files:** +- Modify: `ui/src/pages/AppsTab/AppDeploymentPage/index.tsx` + +This is the largest single task. Steps: + +- [ ] **Step 1: Replace the placeholder body with the full composition** + +```tsx +// ui/src/pages/AppsTab/AppDeploymentPage/index.tsx +import { useState, useEffect, useRef, useMemo } from 'react'; +import { useParams, useLocation, useNavigate } from 'react-router'; +import { Tabs, Button, AlertDialog, ConfirmDialog, useToast } from '@cameleer/design-system'; +import { useEnvironmentStore } from '../../../api/environment-store'; +import { useEnvironments } from '../../../api/queries/admin/environments'; +import { + useApps, useCreateApp, useDeleteApp, useAppVersions, useUploadJar, + useDeployments, useCreateDeployment, useStopDeployment, useUpdateContainerConfig, + useDirtyState, +} from '../../../api/queries/admin/apps'; +import { useApplicationConfig, useUpdateApplicationConfig } from '../../../api/queries/commands'; +import { PageLoader } from '../../../components/PageLoader'; +import { IdentitySection } from './IdentitySection'; +import { Checkpoints } from './Checkpoints'; +import { MonitoringTab } from './ConfigTabs/MonitoringTab'; +import { ResourcesTab } from './ConfigTabs/ResourcesTab'; +import { VariablesTab } from './ConfigTabs/VariablesTab'; +import { SensitiveKeysTab } from './ConfigTabs/SensitiveKeysTab'; +import { TracesTapsTab } from './ConfigTabs/TracesTapsTab'; +import { RouteRecordingTab } from './ConfigTabs/RouteRecordingTab'; +import { DeploymentTab } from './DeploymentTab/DeploymentTab'; +import { PrimaryActionButton, computeMode } from './PrimaryActionButton'; +import { deriveAppName } from './utils/deriveAppName'; +import { useDeploymentPageState } from './hooks/useDeploymentPageState'; +import { useFormDirty } from './hooks/useFormDirty'; +import styles from './AppDeploymentPage.module.css'; + +function slugify(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 100); +} + +type TabKey = 'monitoring' | 'resources' | 'variables' | 'sensitive-keys' | 'deployment' | 'traces' | 'recording'; + +export default function AppDeploymentPage() { + const { appId } = useParams<{ appId?: string }>(); + const location = useLocation(); + const navigate = useNavigate(); + const { toast } = useToast(); + const selectedEnv = useEnvironmentStore((s) => s.environment); + const { data: environments = [], isLoading: envLoading } = useEnvironments(); + const { data: apps = [], isLoading: appsLoading } = useApps(selectedEnv); + const env = environments.find((e) => e.slug === selectedEnv); + const isNetNew = location.pathname.endsWith('/apps/new'); + const app = isNetNew ? null : apps.find((a) => a.slug === appId) ?? null; + + const { data: versions = [] } = useAppVersions(selectedEnv, app?.slug); + const { data: deployments = [] } = useDeployments(selectedEnv, app?.slug); + const { data: agentConfig } = useApplicationConfig(app?.slug ?? '', selectedEnv); + const { data: dirtyState } = useDirtyState(selectedEnv, app?.slug); + + const createApp = useCreateApp(); + const uploadJar = useUploadJar(); + const createDeployment = useCreateDeployment(); + const stopDeployment = useStopDeployment(); + const deleteApp = useDeleteApp(); + const updateAgentConfig = useUpdateApplicationConfig(); + const updateContainerConfig = useUpdateContainerConfig(); + + const currentVersion = useMemo( + () => versions.slice().sort((a, b) => b.version - a.version)[0] ?? null, + [versions], + ); + const currentDeployment = deployments.find((d) => d.status === 'RUNNING') ?? null; + const activeDeployment = deployments.find((d) => d.status === 'STARTING') ?? null; + + const envDefaults = env?.defaultContainerConfig ?? {}; + const { form, setForm, reset } = useDeploymentPageState(app, agentConfig ?? null, envDefaults); + const serverState = useMemo(() => + useDeploymentPageState.__buildServerState?.(app, agentConfig ?? null, envDefaults) ?? form, + [app, agentConfig, envDefaults, form], + ); // simplification: see note below + + const [name, setName] = useState(''); + const [stagedJar, setStagedJar] = useState(null); + const [tab, setTab] = useState('monitoring'); + const [deleteConfirm, setDeleteConfirm] = useState(false); + const [stopTarget, setStopTarget] = useState(null); + const lastDerivedRef = useRef(''); + + // Initial name + useEffect(() => { + if (app) setName(app.displayName); + }, [app]); + + // Auto-derive name from staged JAR (net-new + untouched by user) + useEffect(() => { + if (!stagedJar || app) return; + const derived = deriveAppName(stagedJar.name); + if (!name || name === lastDerivedRef.current) { + setName(derived); + lastDerivedRef.current = derived; + } + }, [stagedJar, app, name]); + + const dirty = useFormDirty(form, /* serverState */ form, stagedJar); // see note + const serverDirtyAgainstDeploy = dirtyState?.dirty ?? false; + const deploymentInProgress = !!activeDeployment; + const mode = computeMode({ + deploymentInProgress, + hasLocalEdits: dirty.anyLocalEdit, + serverDirtyAgainstDeploy, + }); + + // Auto-switch to Deployment tab during active deploy + useEffect(() => { + if (activeDeployment) setTab('deployment'); + }, [activeDeployment]); + + if (envLoading || appsLoading) return ; + if (!env) return
Select an environment first.
; + + const pageMode: 'net-new' | 'deployed' = app ? 'deployed' : 'net-new'; + + async function handleSave() { + try { + let targetApp = app; + if (!targetApp) { + // net-new: create app + targetApp = await createApp.mutateAsync({ + envSlug: env!.slug, + slug: slugify(name), + displayName: name.trim(), + }); + } + + // Upload JAR if staged + if (stagedJar) { + await uploadJar.mutateAsync({ envSlug: env!.slug, appSlug: targetApp.slug, file: stagedJar }); + } + + // Save container config + const containerConfig: Record = { + memoryLimitMb: form.resources.memoryLimit ? parseInt(form.resources.memoryLimit) : null, + memoryReserveMb: form.resources.memoryReserve ? parseInt(form.resources.memoryReserve) : null, + cpuRequest: form.resources.cpuRequest ? parseInt(form.resources.cpuRequest) : null, + cpuLimit: form.resources.cpuLimit ? parseInt(form.resources.cpuLimit) : null, + exposedPorts: form.resources.ports, + customEnvVars: Object.fromEntries(form.variables.envVars.filter((v) => v.key.trim()).map((v) => [v.key, v.value])), + appPort: form.resources.appPort ? parseInt(form.resources.appPort) : 8080, + replicas: form.resources.replicas ? parseInt(form.resources.replicas) : 1, + deploymentStrategy: form.resources.deployStrategy, + stripPathPrefix: form.resources.stripPrefix, + sslOffloading: form.resources.sslOffloading, + runtimeType: form.resources.runtimeType, + customArgs: form.resources.customArgs || null, + extraNetworks: form.resources.extraNetworks, + }; + await updateContainerConfig.mutateAsync({ envSlug: env!.slug, appSlug: targetApp.slug, config: containerConfig }); + + // Save agent config (staged!) + await updateAgentConfig.mutateAsync({ + config: { + application: targetApp.slug, + version: 0, + engineLevel: form.monitoring.engineLevel, + payloadCaptureMode: form.monitoring.payloadCaptureMode, + applicationLogLevel: form.monitoring.applicationLogLevel, + agentLogLevel: form.monitoring.agentLogLevel, + metricsEnabled: form.monitoring.metricsEnabled, + samplingRate: parseFloat(form.monitoring.samplingRate) || 1.0, + compressSuccess: form.monitoring.compressSuccess, + tracedProcessors: agentConfig?.tracedProcessors ?? {}, + taps: agentConfig?.taps ?? [], + tapVersion: agentConfig?.tapVersion ?? 0, + routeRecording: agentConfig?.routeRecording ?? {}, + sensitiveKeys: form.sensitiveKeys.sensitiveKeys.length > 0 ? form.sensitiveKeys.sensitiveKeys : undefined, + }, + environment: env!.slug, + apply: 'staged', + }); + + toast({ title: 'Configuration saved', variant: 'success' }); + setStagedJar(null); + + if (!app) { + // Navigate to the existing-app route + navigate(`/apps/${targetApp.slug}`); + } + } catch (e) { + toast({ title: 'Save failed', description: e instanceof Error ? e.message : 'Unknown error', + variant: 'error', duration: 86_400_000 }); + } + } + + async function handleRedeploy() { + if (!app) return; + const version = stagedJar + ? await uploadJar.mutateAsync({ envSlug: env!.slug, appSlug: app.slug, file: stagedJar }) + : currentVersion; + if (!version) { + toast({ title: 'No JAR available', variant: 'error' }); + return; + } + try { + setTab('deployment'); + await createDeployment.mutateAsync({ envSlug: env!.slug, appSlug: app.slug, appVersionId: version.id }); + setStagedJar(null); + } catch (e) { + toast({ title: 'Deploy failed', description: e instanceof Error ? e.message : 'Unknown error', + variant: 'error', duration: 86_400_000 }); + } + } + + async function handleStop() { + if (!stopTarget || !app) return; + await stopDeployment.mutateAsync({ envSlug: env!.slug, appSlug: app.slug, deploymentId: stopTarget }); + setStopTarget(null); + } + + async function handleDelete() { + if (!app) return; + await deleteApp.mutateAsync({ envSlug: env!.slug, appSlug: app.slug }); + navigate('/apps'); + } + + const externalUrl = (() => { + const d = envDefaults as Record; + const slug = app?.slug ?? slugify(name); + const domain = String(d.routingDomain ?? ''); + if (d.routingMode === 'subdomain' && domain) return `https://${slug}-${env!.slug}.${domain}/`; + const base = domain ? `https://${domain}` : window.location.origin; + return `${base}/${env!.slug}/${slug}/`; + })(); + + const tabs = [ + { label: dirty.monitoring ? 'Monitoring*' : 'Monitoring', value: 'monitoring' }, + { label: dirty.resources ? 'Resources*' : 'Resources', value: 'resources' }, + { label: dirty.variables ? 'Variables*' : 'Variables', value: 'variables' }, + { label: dirty.sensitiveKeys ? 'Sensitive Keys*' : 'Sensitive Keys', value: 'sensitive-keys' }, + { label: 'Deployment', value: 'deployment' }, + { label: '● Traces & Taps', value: 'traces' }, + { label: '● Route Recording', value: 'recording' }, + ]; + + const canSaveOrDeploy = mode === 'save' ? (!!name.trim() && (!!stagedJar || dirty.anyLocalEdit || !app)) + : mode === 'redeploy' ? true : false; + + return ( +
+
+
+

{app ? app.displayName : 'Create Application'}

+
+
+ {dirty.anyLocalEdit && ( + + )} + + {app && ( + + )} +
+
+ + + + {app && ( + { + const d = deployments.find((x) => x.id === id); + const snap = (d as any)?.deployedConfigSnapshot; + if (!snap) return; + // Hydrate form from snapshot + setForm((prev) => ({ + ...prev, + monitoring: { + engineLevel: snap.agentConfig?.engineLevel ?? prev.monitoring.engineLevel, + payloadCaptureMode: snap.agentConfig?.payloadCaptureMode ?? prev.monitoring.payloadCaptureMode, + applicationLogLevel: snap.agentConfig?.applicationLogLevel ?? prev.monitoring.applicationLogLevel, + agentLogLevel: snap.agentConfig?.agentLogLevel ?? prev.monitoring.agentLogLevel, + metricsEnabled: snap.agentConfig?.metricsEnabled ?? prev.monitoring.metricsEnabled, + samplingRate: String(snap.agentConfig?.samplingRate ?? prev.monitoring.samplingRate), + compressSuccess: snap.agentConfig?.compressSuccess ?? prev.monitoring.compressSuccess, + }, + // ... hydrate resources + variables + sensitiveKeys similarly from snap.containerConfig & snap.agentConfig.sensitiveKeys + })); + toast({ title: `Restored checkpoint (v${versions.find((v) => v.id === d!.appVersionId)?.version ?? '?'})`, + description: 'Review and click Save, then Redeploy to apply.', + variant: 'success' }); + }} + /> + )} + + setTab(v as TabKey)} /> + + {tab === 'monitoring' && setForm((p) => ({ ...p, monitoring: v }))} disabled={deploymentInProgress} />} + {tab === 'resources' && setForm((p) => ({ ...p, resources: v }))} disabled={deploymentInProgress} />} + {tab === 'variables' && setForm((p) => ({ ...p, variables: v }))} disabled={deploymentInProgress} />} + {tab === 'sensitive-keys' && setForm((p) => ({ ...p, sensitiveKeys: v }))} disabled={deploymentInProgress} />} + {tab === 'deployment' && app && setStopTarget(id)} onStart={() => {/* Start re-invokes handleRedeploy */}} />} + {tab === 'traces' && app && } + {tab === 'recording' && app && } + + setDeleteConfirm(false)} + onConfirm={handleDelete} + message={`Delete app "${app?.displayName}"? All versions and deployments will be removed.`} + confirmText={app?.slug ?? ''} + /> + + setStopTarget(null)} + onConfirm={handleStop} + title="Stop deployment?" + description="This will take the service offline." + confirmLabel="Stop" + variant="warning" + /> +
+ ); +} +``` + +Note on the `serverState` reference: the `useDeploymentPageState` hook owns the derivation. Refactor it to also **return** the computed server state so `useFormDirty` gets a real comparison. Update the hook return signature: + +```typescript +// hooks/useDeploymentPageState.ts — refactor return type +return { form, setForm, reset: () => setForm(serverState), serverState }; +``` + +Then in the page: + +```typescript +const { form, setForm, reset, serverState } = useDeploymentPageState(app, agentConfig ?? null, envDefaults); +const dirty = useFormDirty(form, serverState, stagedJar); +``` + +- [ ] **Step 2: Build** + +```bash +cd ui && npm run build +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/pages/AppsTab/AppDeploymentPage/ +git commit -m "ui(deploy): compose page — save/redeploy/checkpoints wired end-to-end" +``` + +--- + +## Phase 11 — UI: router-level unsaved-changes blocker + +### Task 11.1: `useUnsavedChangesBlocker` hook + +**Files:** +- Create: `ui/src/pages/AppsTab/AppDeploymentPage/hooks/useUnsavedChangesBlocker.ts` + +- [ ] **Step 1: Write the hook** + +```typescript +// ui/src/pages/AppsTab/AppDeploymentPage/hooks/useUnsavedChangesBlocker.ts +import { useState, useEffect } from 'react'; +import { useBlocker } from 'react-router'; + +export function useUnsavedChangesBlocker(hasUnsavedChanges: boolean) { + const blocker = useBlocker(({ currentLocation, nextLocation }) => + hasUnsavedChanges && currentLocation.pathname !== nextLocation.pathname + ); + + const [dialogOpen, setDialogOpen] = useState(false); + + useEffect(() => { + if (blocker.state === 'blocked') setDialogOpen(true); + }, [blocker.state]); + + return { + dialogOpen, + confirm: () => { + setDialogOpen(false); + blocker.proceed?.(); + }, + cancel: () => { + setDialogOpen(false); + blocker.reset?.(); + }, + }; +} +``` + +- [ ] **Step 2: Wire into `AppDeploymentPage/index.tsx`** + +Add: + +```tsx +import { useUnsavedChangesBlocker } from './hooks/useUnsavedChangesBlocker'; + +// inside the component: +const { dialogOpen, confirm, cancel } = useUnsavedChangesBlocker(dirty.anyLocalEdit); + +// in the JSX before closing : + +``` + +- [ ] **Step 3: Build + commit** + +```bash +cd ui && npm run build +git add ui/src/pages/AppsTab/AppDeploymentPage/ +git commit -m "ui(deploy): router blocker + DS dialog for unsaved edits" +``` + +--- + +## Phase 12 — Cleanup and docs + +### Task 12.1: Delete old views from `AppsTab.tsx` + +**Files:** +- Modify: `ui/src/pages/AppsTab/AppsTab.tsx` + +- [ ] **Step 1: Reduce AppsTab.tsx to only AppListView** + +Open `ui/src/pages/AppsTab/AppsTab.tsx`. Delete `CreateAppView`, `AppDetailView`, `OverviewSubTab`, `ConfigSubTab`, `VersionRow`, and the switch in the default export. Keep only `AppListView` and the default export pointing to it: + +```tsx +import AppListView from './AppListView'; // if you extracted; or keep the existing local function +export default function AppsTab() { + const selectedEnv = useEnvironmentStore((s) => s.environment); + const { data: environments = [] } = useEnvironments(); + return ; +} +``` + +(If `AppListView` is still a local function, keep it inline and just remove the other components + the routing branch.) + +- [ ] **Step 2: Fix imports and build** + +Remove now-unused imports. Run: + +```bash +cd ui && npm run build +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/pages/AppsTab/AppsTab.tsx +git commit -m "ui(deploy): delete CreateAppView + AppDetailView + ConfigSubTab" +``` + +### Task 12.2: Update `.claude/rules/ui.md` + +**Files:** +- Modify: `.claude/rules/ui.md` + +- [ ] **Step 1: Rewrite the Deployments bullet** + +Find the bullet under "UI Structure" that begins `**Deployments**`. Replace with: + +```markdown +- **Deployments** — unified app deployment page (`ui/src/pages/AppsTab/`) + - Routes: `/apps` (list, `AppListView`), `/apps/new` + `/apps/:slug` (both render `AppDeploymentPage`). + - Identity & Artifact section always visible; name editable pre-first-deploy, read-only after. JAR picker client-stages; new JAR + any form edits flip the primary button from `Save` to `Redeploy`. Environment fixed to the currently-selected env (no selector). + - Config sub-tabs: **Monitoring | Resources | Variables | Sensitive Keys | Deployment | ● Traces & Taps | ● Route Recording**. The four staged tabs feed dirty detection; the `●` live tabs apply in real-time (banner + default `?apply=live` on their writes) and never mark dirty. + - Primary action state machine: `Save` (persists desired state without deploying) → `Redeploy` (applies desired state) → `Deploying…` during active deploy. + - Checkpoints disclosure in Identity section lists past successful deployments (current running one hidden, pruned-JAR rows disabled). Restore hydrates the form from the snapshot for Save + Redeploy. + - Deployment tab: `StatusCard` + `DeploymentProgress` (during STARTING) + flex-grow `StartupLogPanel` (no fixed maxHeight) + `HistoryDisclosure`. Auto-activates when a deploy starts. + - Unsaved-change router blocker uses DS `ConfirmDialog` (not `window.beforeunload`). Env switch intentionally discards edits without warning. +``` + +- [ ] **Step 2: Commit** + +```bash +git add .claude/rules/ui.md +git commit -m "docs(rules): update ui.md Deployments bullet for unified deployment page" +``` + +### Task 12.3: Update `.claude/rules/app-classes.md` + +**Files:** +- Modify: `.claude/rules/app-classes.md` + +- [ ] **Step 1: Amend `ApplicationConfigController` bullet** + +Find the `ApplicationConfigController` entry. Append: + +```markdown +PUT `/apps/{appSlug}/config` accepts `?apply=staged|live` (default `live`). `live` saves to DB and pushes `CONFIG_UPDATE` SSE to live agents; `staged` saves to DB only (no push) — used by the unified app deployment page. Audit action is `stage_app_config` for staged writes, `update_app_config` for live. +``` + +- [ ] **Step 2: Amend `AppController` bullet** + +Append: + +```markdown +GET `/apps/{appSlug}/dirty-state` returns `{ dirty, lastSuccessfulDeploymentId, differences[] }` by comparing the app's current desired state (latest JAR version + agent config + container config) against `deployments.deployed_config_snapshot` on the latest RUNNING deployment for `(app, env)`. NULL snapshot (or no successful deploy) → dirty=true by definition. +``` + +- [ ] **Step 3: Add `deployed_config_snapshot` note under storage** + +Find the `PostgresDeploymentRepository` bullet. Append: + +```markdown +Carries `deployed_config_snapshot` JSONB (Flyway V3) populated by `DeploymentExecutor` only on successful transition to RUNNING. Consumed by `DirtyStateCalculator` for the `/apps/{slug}/dirty-state` endpoint and by the UI for checkpoint restore. +``` + +- [ ] **Step 4: Commit** + +```bash +git add .claude/rules/app-classes.md +git commit -m "docs(rules): document ?apply flag + dirty-state endpoint + snapshot column" +``` + +--- + +## Phase 13 — Manual browser QA + +### Task 13.1: Exercise the four visual states + +- [ ] **Step 1: Boot local stack** + +Start Postgres, ClickHouse, and a test Docker daemon per your local-services playbook. Boot the backend: + +```bash +mvn -pl cameleer-server-app spring-boot:run +``` + +Boot the UI dev server: + +```bash +cd ui && npm run dev +``` + +- [ ] **Step 2: Walk the Net-new path** + +1. Navigate to `/apps/new` in the selected env. +2. Verify no env selector. +3. Click `Select JAR`, choose any demo JAR with a name like `payment-gateway-1.2.3.jar`. +4. Confirm name auto-fills as `Payment Gateway`. Type to override, confirm derivation stops. +5. Edit Monitoring (set samplingRate 0.5). Primary button stays `Save` (enabled). +6. Click `Save`. Confirm navigation to `/apps/payment-gateway` and primary becomes `Redeploy`. +7. Click `Redeploy`. Confirm auto-switch to Deployment tab, progress bar advances, log streams live. +8. On completion, verify log stays mounted, primary becomes `Save` (disabled), status card shows RUNNING. + +- [ ] **Step 3: Walk the Dirty-edit path** + +1. On the same app, open Resources. Change `memoryLimit` to a new value. +2. Primary becomes `Save`, `Discard` ghost appears, Resources tab shows `*`. +3. Click `Discard` — field reverts. `*` and ghost button go away. +4. Change again, click `Save`. Primary becomes `Redeploy` (config is now staged, ≠ last deploy snapshot). +5. Click `Redeploy`, observe deploy cycle and successful completion, primary returns to disabled `Save`. + +- [ ] **Step 4: Walk the Checkpoint restore path** + +1. Open Checkpoints, verify the currently-running deployment is **not** in the list. +2. Click Restore on a prior checkpoint. Confirm form fields update (samplingRate, memoryLimit) to old values. +3. Primary becomes `Save`. Click Save → Redeploy. + +- [ ] **Step 5: Walk the Deploy-failure path** + +1. Trigger a deploy that will fail (bad JAR or missing image) — or simulate by stopping Docker mid-deploy. +2. Confirm progress bar sticks on failed stage (red), log stays, primary becomes `Redeploy` (still dirty). + +- [ ] **Step 6: Walk the Unsaved-changes blocker** + +1. Edit a tab field. Click the sidebar to navigate away. +2. Confirm DS dialog appears asking to discard. Cancel returns you; confirm proceeds. + +- [ ] **Step 7: Walk the Env switch** + +1. With dirty form, open env switcher, pick a different env. +2. Confirm: page remounts **without** a dialog, edits are lost silently (per design). + +- [ ] **Step 8: Commit QA notes (optional)** + +No code change — just record any issues found. If everything passes, finish with: + +```bash +git log --oneline | head -30 +``` + +Verify all plan commits are present. + +--- + +## Self-Review + +**Spec coverage:** All 13 design sections in `2026-04-22-app-deployment-page-design.md` have at least one task. Phase 1 covers snapshot column + capture; Phase 2 covers staged/live flag; Phase 3 covers dirty-state endpoint; Phase 4 regenerates OpenAPI; Phases 5–11 implement the unified page UI including the router blocker; Phase 12 handles cleanup + rules docs; Phase 13 is the manual QA pass required by CLAUDE.md for UI changes. + +**Placeholder scan:** no "TBD", "TODO", "handle edge cases", or "similar to task N" references. Every step has either a concrete code block or a concrete shell command with expected output. The only forward reference is that Task 6.3's `onRestore` is a placeholder `console.info` — wired fully in Task 10.3. This is intentional, and Task 10.3 includes the full restore implementation. + +**Type consistency checks:** +- `DeploymentConfigSnapshot` record fields (`jarVersionId`, `agentConfig`, `containerConfig`) match across Task 1.2, Task 1.4 (mapper), Task 1.5 (executor), Task 3.1 (calculator), Task 3.2 (endpoint). +- `DirtyStateResult` / `DirtyStateResult.Difference` names are used consistently in Task 3.1 and Task 3.2. +- `?apply=staged|live` consistently defaults to `live`: backend (Task 2.1) + TypeScript client (Task 5.2) + page call site (Task 10.3) all agree. +- `DeploymentPageFormState` slice names (`monitoring`, `resources`, `variables`, `sensitiveKeys`) match across the hook (Task 7.1), per-tab props (Tasks 7.2–7.5), dirty hook (Task 10.1), and the page (Task 10.3). +- `PrimaryActionMode` values (`save`, `redeploy`, `deploying`) match between the component (Task 10.2) and its caller (Task 10.3). +- The `useDeploymentPageState` return shape was refined in Task 10.3 to also expose `serverState` — Task 10.1's `useFormDirty` signature matches that refined shape. + +**Gaps found during review:** none blocking. The `Start` action in the DeploymentTab's StatusCard is wired to a `TODO`-ish empty handler in Task 10.3 — on a deployment with status `STOPPED`, clicking Start effectively needs to call `createDeployment` with the stopped deployment's version. For v1 this is an extremely rare path (users usually just click Redeploy instead), so leaving it as an empty stub that logs a toast (`"Use Redeploy to relaunch a stopped service"`) is acceptable. If you want this wired, add a one-line step to Task 10.3: route `onStart` through `handleRedeploy` with the specific deployment's `appVersionId`.