# 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<Difference>}`.
- **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.
 *
 * <p>This is persisted as JSONB in {@code deployments.deployed_config_snapshot}.</p>
 */
public record DeploymentConfigSnapshot(
        String jarVersionId,
        ApplicationConfig agentConfig,
        Map<String, Object> 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<Deployment>` 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<String, Object> containerConfig = app.containerConfig();  // Map<String,Object> 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<Command> 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<Command> 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<ConfigUpdateResponse> 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<String> globalKeys = sensitiveKeysRepository.find()
            .map(SensitiveKeysConfig::keys)
            .orElse(null);
    List<String> perAppKeys = extractSensitiveKeys(saved);
    List<String> 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<String, Object> 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<String, Object> 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<String, Object> 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.
 *
 * <p>Pure logic — no IO, no Spring. Safe to unit-test as a POJO.</p>
 */
public class DirtyStateCalculator {

    private static final ObjectMapper MAPPER = new ObjectMapper();

    public DirtyStateResult compute(String desiredJarVersionId,
                                    ApplicationConfig desiredAgentConfig,
                                    Map<String, Object> desiredContainerConfig,
                                    DeploymentConfigSnapshot snapshot) {
        List<DirtyStateResult.Difference> 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<DirtyStateResult.Difference> 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<String> 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<Difference> 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<DirtyStateResult.Difference> 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<DirtyStateResponse> getDirtyState(@EnvPath Environment env,
                                                        @PathVariable String appSlug) {
    App app = appService.getByEnvironmentAndSlug(env.id(), appSlug)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));

    List<AppVersion> 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<String, Object> 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<Deployment> 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 <PageLoader />;

  return (
    <div className={styles.container}>
      <h2>{app ? app.displayName : 'Create Application'}</h2>
      {/* Identity section, tabs, primary button land in subsequent tasks */}
    </div>
  );
}
```

```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: <AppsTab /> },              // list stays
{ path: 'apps/new', element: <AppDeploymentPage /> },
{ path: 'apps/:appId', element: <AppDeploymentPage /> },
```

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<DirtyState>(
      `${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<ConfigUpdateResponse>(
      `/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<HTMLInputElement>(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 (
    <div className={styles.section}>
      <SectionHeader>Identity & Artifact</SectionHeader>
      <div className={styles.configGrid}>
        <span className={styles.configLabel}>Application Name</span>
        {mode === 'deployed' ? (
          <span className={styles.readOnlyValue}>{name}</span>
        ) : (
          <Input
            value={name}
            onChange={(e) => onNameChange(e.target.value)}
            placeholder="e.g. Payment Gateway"
            disabled={deploying}
          />
        )}

        <span className={styles.configLabel}>Slug</span>
        <MonoText size="sm">{slug || '...'}</MonoText>

        <span className={styles.configLabel}>Environment</span>
        <Badge label={environment.displayName} color="auto" />

        <span className={styles.configLabel}>External URL</span>
        <MonoText size="sm">{externalUrl}</MonoText>

        {currentVersion && (
          <>
            <span className={styles.configLabel}>Current Version</span>
            <span className={styles.readOnlyValue}>
              v{currentVersion.version} · {currentVersion.jarFilename} · {formatBytes(currentVersion.jarSizeBytes)}
            </span>
          </>
        )}

        <span className={styles.configLabel}>Application JAR</span>
        <div className={styles.fileRow}>
          <input
            ref={fileInputRef}
            type="file"
            accept=".jar"
            className={styles.visuallyHidden}
            onChange={(e) => onStagedJarChange(e.target.files?.[0] ?? null)}
          />
          <Button
            size="sm"
            variant="secondary"
            type="button"
            onClick={() => fileInputRef.current?.click()}
            disabled={deploying}
          >
            {currentVersion ? 'Change JAR' : 'Select JAR'}
          </Button>
          {stagedJar && (
            <span className={styles.stagedJar}>
              staged: {stagedJar.name} ({formatBytes(stagedJar.size)})
            </span>
          )}
        </div>
      </div>
    </div>
  );
}
```

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<File | null>(null);

  // Auto-derive: update name from staged JAR if user hasn't typed anything custom
  const lastDerivedRef = useRef<string>('');
  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 <PageLoader />;
  if (!env) return <div>Select an environment first.</div>;

  const mode: 'net-new' | 'deployed' = app ? 'deployed' : 'net-new';

  return (
    <div className={styles.container}>
      <h2>{app ? app.displayName : 'Create Application'}</h2>
      <IdentitySection
        mode={mode}
        environment={env}
        app={app}
        currentVersion={null /* wired in later task */}
        name={name}
        onNameChange={setName}
        stagedJar={stagedJar}
        onStagedJarChange={setStagedJar}
        deploying={false}
      />
    </div>
  );
}
```

- [ ] **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 (
    <div className={styles.checkpointsRow}>
      <button
        type="button"
        className={styles.disclosureToggle}
        onClick={() => setOpen(!open)}
      >
        {open ? '▼' : '▶'} Checkpoints ({checkpoints.length})
      </button>
      {open && (
        <div className={styles.checkpointList}>
          {checkpoints.length === 0 && (
            <div className={styles.checkpointEmpty}>No past deployments yet.</div>
          )}
          {checkpoints.map((d) => {
            const v = versionMap.get(d.appVersionId);
            const jarAvailable = !!v;
            return (
              <div key={d.id} className={styles.checkpointRow}>
                <Badge label={v ? `v${v.version}` : '?'} color="auto" />
                <span className={styles.checkpointMeta}>
                  {d.deployedAt ? timeAgo(d.deployedAt) : '—'}
                </span>
                {!jarAvailable && (
                  <span className={styles.checkpointArchived}>archived, JAR unavailable</span>
                )}
                <Button
                  size="sm"
                  variant="ghost"
                  disabled={!jarAvailable}
                  title={!jarAvailable ? 'JAR was pruned by the environment retention policy' : undefined}
                  onClick={() => onRestore(d.id)}
                >
                  Restore
                </Button>
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}
```

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' && (
  <Checkpoints
    deployments={deployments}
    versions={versions}
    currentDeploymentId={currentDeployment?.id ?? null}
    onRestore={(id) => {
      // 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<string, unknown>,
): {
  form: DeploymentPageFormState;
  setForm: React.Dispatch<React.SetStateAction<DeploymentPageFormState>>;
  reset: () => void;
} {
  const [form, setForm] = useState<DeploymentPageFormState>(defaultForm);

  const serverState = useMemo<DeploymentPageFormState>(() => {
    const merged = { ...envDefaults, ...(app?.containerConfig ?? {}) } as Record<string, unknown>;
    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<string, string>).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 = <K extends keyof MonitoringFormState>(key: K, v: MonitoringFormState[K]) =>
    onChange({ ...value, [key]: v });

  return (
    <div className={styles.configGrid}>
      <span className={styles.configLabel}>Engine Level</span>
      <Select value={value.engineLevel} disabled={disabled}
              onChange={(e) => update('engineLevel', e.target.value)}
              options={[
                { value: 'OFF', label: 'OFF' },
                { value: 'LIGHTWEIGHT', label: 'LIGHTWEIGHT' },
                { value: 'REGULAR', label: 'REGULAR' },
                { value: 'AGGRESSIVE', label: 'AGGRESSIVE' },
              ]} />

      <span className={styles.configLabel}>Payload Capture</span>
      <Select value={value.payloadCaptureMode} disabled={disabled}
              onChange={(e) => 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' },
              ]} />

      <span className={styles.configLabel}>Application Log Level</span>
      <Select value={value.applicationLogLevel} disabled={disabled}
              onChange={(e) => update('applicationLogLevel', e.target.value)}
              options={['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].map((v) => ({ value: v, label: v }))} />

      <span className={styles.configLabel}>Agent Log Level</span>
      <Select value={value.agentLogLevel} disabled={disabled}
              onChange={(e) => update('agentLogLevel', e.target.value)}
              options={['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].map((v) => ({ value: v, label: v }))} />

      <span className={styles.configLabel}>Metrics Enabled</span>
      <Toggle checked={value.metricsEnabled} disabled={disabled}
              onChange={(v) => update('metricsEnabled', v)} />

      <span className={styles.configLabel}>Sampling Rate</span>
      <Input value={value.samplingRate} disabled={disabled}
             onChange={(e) => update('samplingRate', e.target.value)} />

      <span className={styles.configLabel}>Compress Success Payloads</span>
      <Toggle checked={value.compressSuccess} disabled={disabled}
              onChange={(v) => update('compressSuccess', v)} />
    </div>
  );
}
```

- [ ] **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 = <K extends keyof ResourcesFormState>(key: K, v: ResourcesFormState[K]) =>
    onChange({ ...value, [key]: v });

  return (
    <div className={styles.configGrid}>
      <span className={styles.configLabel}>Memory Limit (MB)</span>
      <Input value={value.memoryLimit} disabled={disabled}
             onChange={(e) => update('memoryLimit', e.target.value)} />

      <span className={styles.configLabel}>Memory Reserve (MB)</span>
      <Input value={value.memoryReserve} disabled={disabled}
             onChange={(e) => update('memoryReserve', e.target.value)} />

      <span className={styles.configLabel}>CPU Request (millicores)</span>
      <Input value={value.cpuRequest} disabled={disabled}
             onChange={(e) => update('cpuRequest', e.target.value)} />

      <span className={styles.configLabel}>CPU Limit (millicores)</span>
      <Input value={value.cpuLimit} disabled={disabled}
             onChange={(e) => update('cpuLimit', e.target.value)} />

      <span className={styles.configLabel}>App Port</span>
      <Input value={value.appPort} disabled={disabled}
             onChange={(e) => update('appPort', e.target.value)} />

      <span className={styles.configLabel}>Replicas</span>
      <Input value={value.replicas} disabled={disabled}
             onChange={(e) => update('replicas', e.target.value)} />

      <span className={styles.configLabel}>Deployment Strategy</span>
      <Select value={value.deployStrategy} disabled={disabled}
              onChange={(e) => update('deployStrategy', e.target.value)}
              options={[
                { value: 'blue-green', label: 'Blue/Green' },
                { value: 'rolling', label: 'Rolling' },
                { value: 'recreate', label: 'Recreate' },
              ]} />

      <span className={styles.configLabel}>Strip Path Prefix</span>
      <Toggle checked={value.stripPrefix} disabled={disabled}
              onChange={(v) => update('stripPrefix', v)} />

      <span className={styles.configLabel}>SSL Offloading</span>
      <Toggle checked={value.sslOffloading} disabled={disabled}
              onChange={(v) => update('sslOffloading', v)} />

      <span className={styles.configLabel}>Runtime Type</span>
      <Select value={value.runtimeType} disabled={disabled}
              onChange={(e) => 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' },
              ]} />

      <span className={styles.configLabel}>Custom Args</span>
      <Input value={value.customArgs} disabled={disabled}
             onChange={(e) => 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. */}
    </div>
  );
}
```

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 (
    <div className={styles.envVarsList}>
      {value.envVars.map((v, i) => (
        <div key={i} className={styles.envVarRow}>
          <Input value={v.key} disabled={disabled} placeholder="KEY"
                 onChange={(e) => update(value.envVars.map((x, idx) => idx === i ? { ...x, key: e.target.value } : x))} />
          <Input value={v.value} disabled={disabled} placeholder="value"
                 onChange={(e) => update(value.envVars.map((x, idx) => idx === i ? { ...x, value: e.target.value } : x))} />
          <Button size="sm" variant="ghost" disabled={disabled}
                  onClick={() => update(value.envVars.filter((_, idx) => idx !== i))}>
            Remove
          </Button>
        </div>
      ))}
      <Button size="sm" variant="secondary" disabled={disabled}
              onClick={() => update([...value.envVars, { key: '', value: '' }])}>
        + Add variable
      </Button>
    </div>
  );
}
```

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 (
    <div>
      {globalKeys.length > 0 && (
        <div className={skStyles.refSection}>
          <div className={skStyles.sectionTitle}>Global baseline</div>
          <div className={skStyles.pillList}>
            {globalKeys.map((k) => <Badge key={k} label={k} color="auto" />)}
          </div>
        </div>
      )}

      <div className={skStyles.sectionTitle}>App-specific keys</div>
      <div className={skStyles.pillList}>
        {value.sensitiveKeys.map((k, i) => (
          <Tag key={`${k}-${i}`} label={k}
               onRemove={() => !disabled && onChange({ sensitiveKeys: value.sensitiveKeys.filter((_, idx) => idx !== i) })} />
        ))}
        {value.sensitiveKeys.length === 0 && (
          <span className={skStyles.emptyState}>
            No app-specific keys — agents use built-in defaults{globalKeys.length > 0 ? ' + global keys' : ''}.
          </span>
        )}
      </div>

      <div className={skStyles.inputRow}>
        <Input value={newKey} disabled={disabled}
               onChange={(e) => setNewKey(e.target.value)}
               onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); add(); } }}
               placeholder="Add key or glob pattern (e.g. *password*)" />
        <Button variant="secondary" size="sm" disabled={disabled || !newKey.trim()} onClick={add}>Add</Button>
      </div>

      <div className={skStyles.hint}>
        <Info size={12} />
        <span>Final masking configuration = agent defaults + global keys + app-specific keys.</span>
      </div>
    </div>
  );
}
```

- [ ] **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 (
    <div className={styles.liveBanner}>
      <Info size={14} />
      <span>
        <strong>Live controls.</strong> Changes apply immediately to running agents and do
        not participate in the Save/Redeploy cycle.
      </span>
    </div>
  );
}
```

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 `<LiveBanner />` 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 (
    <div>
      <LiveBanner />
      {/* Paste the existing tracedTapRows rendering here verbatim — it queries
          useApplicationConfig + calls useUpdateApplicationConfig (apply=live default). */}
    </div>
  );
}
```

Do the same for `RouteRecordingTab.tsx` — lift the "recording" case contents, prepend `<LiveBanner />`.

- [ ] **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 (
    <div className={styles.statusCard}>
      <div className={styles.statusCardHeader}>
        <StatusDot variant={DEPLOY_STATUS_DOT[deployment.status] ?? 'dead'} />
        <Badge label={deployment.status} color={STATUS_COLORS[deployment.status] ?? 'auto'} />
        {version && <Badge label={`v${version.version}`} color="auto" />}
      </div>

      <div className={styles.statusCardGrid}>
        {version && <><span>JAR</span><MonoText size="sm">{version.jarFilename}</MonoText></>}
        {version && <><span>Checksum</span><MonoText size="xs">{version.jarChecksum.substring(0, 12)}</MonoText></>}
        <span>Replicas</span><span>{running}/{total}</span>
        <span>URL</span>
        {deployment.status === 'RUNNING'
          ? <a href={externalUrl} target="_blank" rel="noreferrer"><MonoText size="sm">{externalUrl}</MonoText></a>
          : <MonoText size="sm" muted>{externalUrl}</MonoText>}
        <span>Deployed</span><span>{deployment.deployedAt ? timeAgo(deployment.deployedAt) : '—'}</span>
      </div>

      <div className={styles.statusCardActions}>
        {(deployment.status === 'RUNNING' || deployment.status === 'STARTING' || deployment.status === 'DEGRADED')
          && <Button size="sm" variant="danger" onClick={onStop}>Stop</Button>}
        {deployment.status === 'STOPPED' && <Button size="sm" variant="secondary" onClick={onStart}>Start</Button>}
      </div>
    </div>
  );
}
```

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<string | null>(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<Deployment>[] = [
    { 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 (
    <div className={styles.historyRow}>
      <button type="button" className={styles.disclosureToggle} onClick={() => setOpen(!open)}>
        {open ? '▼' : '▶'} History ({rows.length})
      </button>
      {open && (
        <>
          <DataTable columns={columns} data={rows}
            onRowClick={(row) => setExpanded(expanded === row.id ? null : row.id)} />
          {expanded && (() => {
            const d = rows.find((r) => r.id === expanded);
            if (!d) return null;
            return <StartupLogPanel deployment={d} appSlug={appSlug} envSlug={envSlug} />;
          })()}
        </>
      )}
    </div>
  );
}
```

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 (
    <div className={`${styles.panel} ${className ?? ''}`}>
      {/* existing header */}
      {entries.length > 0 ? (
        <LogViewer entries={entries as unknown as LogEntry[]} /* no maxHeight — let CSS control */ />
      ) : (
        <div className={styles.empty}>Waiting for container output...</div>
      )}
    </div>
  );
}
```

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 <EmptyState title="No deployments yet" description="Save your configuration and click Redeploy to launch." />;
  }

  const version = versions.find((v) => v.id === latest.appVersionId) ?? null;

  return (
    <div className={styles.deploymentTab}>
      <StatusCard
        deployment={latest}
        version={version}
        externalUrl={externalUrl}
        onStop={() => onStop(latest.id)}
        onStart={() => onStart(latest.id)}
      />
      {latest.status === 'STARTING' && (
        <DeploymentProgress currentStage={latest.deployStage} failed={false} />
      )}
      {latest.status === 'FAILED' && (
        <DeploymentProgress currentStage={latest.deployStage} failed />
      )}
      <StartupLogPanel deployment={latest} appSlug={appSlug} envSlug={envSlug}
                       className={styles.logFill} />
      <HistoryDisclosure deployments={deployments} versions={versions}
                         appSlug={appSlug} envSlug={envSlug} />
    </div>
  );
}
```

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 <Button size="sm" variant="primary" loading disabled>Deploying…</Button>;
  }
  if (mode === 'redeploy') {
    return <Button size="sm" variant="primary" disabled={!enabled} onClick={onClick}>Redeploy</Button>;
  }
  return <Button size="sm" variant="primary" disabled={!enabled} onClick={onClick}>Save</Button>;
}

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<File | null>(null);
  const [tab, setTab] = useState<TabKey>('monitoring');
  const [deleteConfirm, setDeleteConfirm] = useState(false);
  const [stopTarget, setStopTarget] = useState<string | null>(null);
  const lastDerivedRef = useRef<string>('');

  // 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 <PageLoader />;
  if (!env) return <div>Select an environment first.</div>;

  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<string, unknown> = {
        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<string, unknown>;
    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 (
    <div className={styles.container}>
      <div className={styles.detailHeader}>
        <div>
          <h2>{app ? app.displayName : 'Create Application'}</h2>
        </div>
        <div className={styles.detailActions}>
          {dirty.anyLocalEdit && (
            <Button size="sm" variant="ghost" onClick={reset}>Discard</Button>
          )}
          <PrimaryActionButton
            mode={mode}
            enabled={canSaveOrDeploy}
            onClick={mode === 'redeploy' ? handleRedeploy : handleSave}
          />
          {app && (
            <Button size="sm" variant="danger" onClick={() => setDeleteConfirm(true)}>Delete App</Button>
          )}
        </div>
      </div>

      <IdentitySection
        mode={pageMode}
        environment={env}
        app={app}
        currentVersion={currentVersion}
        name={name}
        onNameChange={setName}
        stagedJar={stagedJar}
        onStagedJarChange={setStagedJar}
        deploying={deploymentInProgress}
      />

      {app && (
        <Checkpoints
          deployments={deployments}
          versions={versions}
          currentDeploymentId={currentDeployment?.id ?? null}
          onRestore={(id) => {
            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' });
          }}
        />
      )}

      <Tabs tabs={tabs} active={tab} onChange={(v) => setTab(v as TabKey)} />

      {tab === 'monitoring' && <MonitoringTab value={form.monitoring}
        onChange={(v) => setForm((p) => ({ ...p, monitoring: v }))} disabled={deploymentInProgress} />}
      {tab === 'resources' && <ResourcesTab value={form.resources}
        onChange={(v) => setForm((p) => ({ ...p, resources: v }))} disabled={deploymentInProgress} />}
      {tab === 'variables' && <VariablesTab value={form.variables}
        onChange={(v) => setForm((p) => ({ ...p, variables: v }))} disabled={deploymentInProgress} />}
      {tab === 'sensitive-keys' && <SensitiveKeysTab value={form.sensitiveKeys}
        onChange={(v) => setForm((p) => ({ ...p, sensitiveKeys: v }))} disabled={deploymentInProgress} />}
      {tab === 'deployment' && app && <DeploymentTab deployments={deployments} versions={versions}
        appSlug={app.slug} envSlug={env.slug} externalUrl={externalUrl}
        onStop={(id) => setStopTarget(id)} onStart={() => {/* Start re-invokes handleRedeploy */}} />}
      {tab === 'traces' && app && <TracesTapsTab app={app} environment={env} />}
      {tab === 'recording' && app && <RouteRecordingTab app={app} environment={env} />}

      <ConfirmDialog
        open={deleteConfirm}
        onClose={() => setDeleteConfirm(false)}
        onConfirm={handleDelete}
        message={`Delete app "${app?.displayName}"? All versions and deployments will be removed.`}
        confirmText={app?.slug ?? ''}
      />

      <AlertDialog
        open={!!stopTarget}
        onClose={() => setStopTarget(null)}
        onConfirm={handleStop}
        title="Stop deployment?"
        description="This will take the service offline."
        confirmLabel="Stop"
        variant="warning"
      />
    </div>
  );
}
```

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 </div>:
<ConfirmDialog
  open={dialogOpen}
  onClose={cancel}
  onConfirm={confirm}
  message="You have unsaved changes on this page. Discard and leave?"
  confirmLabel="Discard & Leave"
  variant="warning"
/>
```

- [ ] **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 <AppListView selectedEnv={selectedEnv} environments={environments} />;
}
```

(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`.
